Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions lib/textbin/administration.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ defmodule Textbin.Administration do

alias Textbin.Pastes.Paste
alias Textbin.Repo
alias Textbin.Reports.Report

@platform_admin_role "admin"
@authority_lock_key 8_174_021_483_001
Expand Down Expand Up @@ -199,6 +200,33 @@ defmodule Textbin.Administration do
end
end

@doc "Lists the open abuse-report queue oldest first without reporter identity."
def list_reports(scope, opts \\ []) do
with {:ok, _admin} <- authorize_platform_admin(scope) do
limit = page_limit(Keyword.get(opts, :limit))
offset = page_offset(Keyword.get(opts, :page), limit)

reports =
Repo.all(
from report in Report,
where: report.status == "open",
order_by: [asc: report.inserted_at, asc: report.id],
limit: ^(limit + 1),
offset: ^offset,
select: %{
id: report.id,
paste_id: report.paste_id,
category: report.category,
notes: report.notes,
status: report.status,
inserted_at: report.inserted_at
}
)

{:ok, page(reports, limit, Keyword.get(opts, :page))}
end
end

@doc false
def authorize_account_deletion(%Scope{user: %User{id: user_id}} = scope) do
case Repo.transact(fn -> authorize_account_deletion_in_transaction(scope, user_id) end) do
Expand Down Expand Up @@ -336,6 +364,16 @@ defmodule Textbin.Administration do
end
end

@doc "Dismisses an open abuse report with an audited reason."
def dismiss_report(scope, report, reason, opts \\ []) do
review_report(scope, report, reason, "dismissed", "platform.report.dismissed", opts)
end

@doc "Marks an open abuse report resolved with an audited reason."
def resolve_report(scope, report, reason, opts \\ []) do
review_report(scope, report, reason, "actioned", "platform.report.resolved", opts)
end

defp authorize_account_deletion_in_transaction(scope, user_id) do
with :ok <- lock_authority_changes(),
%User{suspended_at: nil} = user <- lock_user(user_id),
Expand Down Expand Up @@ -525,6 +563,42 @@ defmodule Textbin.Administration do
end
end

defp review_report(scope, report, reason, status, action, opts) do
with {:ok, reason} <- normalize_reason(reason),
{:ok, report_id} <- report_id(report) do
report_transaction(
scope,
&review_report_in_transaction(&1, report_id, reason, status, action, opts)
)
end
end

defp review_report_in_transaction(actor, report_id, reason, status, action, opts) do
now = DateTime.utc_now()

with %Report{} = report <- lock_open_report(report_id),
{1, nil} <-
Repo.update_all(
from(candidate in Report,
where: candidate.id == ^report.id and candidate.status == "open"
),
set: [
status: status,
resolution_reason: reason,
resolved_by_user_id: actor.id,
resolved_at: now,
updated_at: now
]
),
:ok <- record_report_audit(actor, action, report, reason, opts) do
{:ok, %{id: report.id, status: status}}
else
nil -> {:error, :not_found}
{0, nil} -> {:error, :not_found}
error -> error
end
end

defp authority_transaction(%Scope{} = scope, callback) do
Repo.transact(fn ->
with :ok <- lock_authority_changes(),
Expand All @@ -537,6 +611,19 @@ defmodule Textbin.Administration do

defp authority_transaction(_scope, _callback), do: {:error, :forbidden}

# Report-only review does not require sudo, but it shares the authority lock
# so revocation cannot race authorization and the audited state transition.
defp report_transaction(%Scope{} = scope, callback) do
Repo.transact(fn ->
with :ok <- lock_authority_changes(),
{:ok, actor} <- lock_platform_admin(scope) do
callback.(actor)
end
end)
end

defp report_transaction(_scope, _callback), do: {:error, :forbidden}

defp lock_platform_admin(%Scope{user: %User{id: user_id}}) do
case lock_user(user_id) do
%User{} = user -> authorize_active_admin(user)
Expand Down Expand Up @@ -674,6 +761,23 @@ defmodule Textbin.Administration do
|> audit_result()
end

defp record_report_audit(actor, action, report, reason, opts) do
%PlatformAuditEvent{}
|> PlatformAuditEvent.changeset(%{
actor_kind: "user",
actor_user_id: actor.id,
actor_label: actor.email,
action: action,
target_type: "report",
target_id: report.id,
reason: reason,
request_id: Keyword.get(opts, :request_id),
metadata: %{"paste_id" => report.paste_id, "category" => report.category}
})
|> Repo.insert()
|> audit_result()
end

defp audit_result({:ok, %PlatformAuditEvent{}}), do: :ok
defp audit_result({:error, changeset}), do: {:error, changeset}

Expand Down Expand Up @@ -704,6 +808,15 @@ defmodule Textbin.Administration do
)
end

defp lock_open_report(report_id) do
Repo.one(
from report in Report,
where: report.id == ^report_id and report.status == "open",
select: struct(report, [:id, :paste_id, :category]),
lock: "FOR UPDATE"
)
end

defp normalize_reason(reason) when is_binary(reason) do
case String.trim(reason) do
"" -> {:error, :reason_required}
Expand Down Expand Up @@ -736,6 +849,17 @@ defmodule Textbin.Administration do

defp paste_id(_id), do: {:error, :not_found}

defp report_id(%Report{id: id}), do: report_id(id)

defp report_id(id) when is_binary(id) do
case Ecto.UUID.cast(id) do
{:ok, id} -> {:ok, id}
:error -> {:error, :not_found}
end
end

defp report_id(_id), do: {:error, :not_found}

defp encode_timestamp(nil), do: nil
defp encode_timestamp(timestamp), do: DateTime.to_iso8601(timestamp)

Expand Down
82 changes: 82 additions & 0 deletions lib/textbin/reports.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
defmodule Textbin.Reports do
@moduledoc """
Abuse-report submission and its non-public reporter boundary.

Reports retain paste and reporter identifiers after either record is deleted,
so those identifiers intentionally are not database foreign keys. Only the
administration context can read or review submitted reports.
"""

import Ecto.Query, warn: false

alias Textbin.Accounts.{Scope, User}
alias Textbin.Organizations.{Organization, Workspace}
alias Textbin.Pastes.Paste
alias Textbin.Repo
alias Textbin.Reports.Report

@doc "Submits an abuse report for an active public or unlisted paste."
def create_report(%Scope{user: %User{id: reporter_id}}, paste_id, attrs)
when is_map(attrs) do
case Ecto.UUID.cast(paste_id) do
{:ok, paste_id} ->
Repo.transact(fn -> create_report_in_transaction(reporter_id, paste_id, attrs) end)

:error ->
{:error, :not_found}
end
end

def create_report(_scope, _paste_id, _attrs), do: {:error, :forbidden}

defp create_report_in_transaction(reporter_id, paste_id, attrs) do
case active_registered_user(reporter_id) do
%User{} = reporter -> insert_report(reporter, paste_id, attrs)
nil -> {:error, :forbidden}
end
end

defp insert_report(reporter, paste_id, attrs) do
case reportable_paste(paste_id) do
%Paste{} = paste ->
%Report{paste_id: paste.id, reporter_user_id: reporter.id}
|> Report.submission_changeset(attrs)
|> Repo.insert()

nil ->
{:error, :not_found}
end
end

defp active_registered_user(user_id) do
Repo.one(
from user in User,
where:
user.id == ^user_id and user.kind == "registered" and
not is_nil(user.confirmed_at) and is_nil(user.suspended_at),
lock: "FOR SHARE"
)
end

defp reportable_paste(paste_id) do
now = Paste.utc_now_ms()

Repo.one(
from paste in Paste,
join: workspace in Workspace,
on: workspace.id == paste.workspace_id,
join: organization in Organization,
on: organization.id == workspace.organization_id,
where:
paste.id == ^paste_id and
(is_nil(paste.expires_at) or paste.expires_at > ^now) and
is_nil(workspace.deletion_requested_at) and
is_nil(organization.deletion_requested_at) and
((paste.audience == "unlisted" and
workspace.external_sharing_policy in ["unlisted", "public"]) or
(paste.audience == "public" and
workspace.external_sharing_policy == "public")),
lock: "FOR SHARE"
)
end
end
38 changes: 38 additions & 0 deletions lib/textbin/reports/report.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
defmodule Textbin.Reports.Report do
use Ecto.Schema

import Ecto.Changeset

@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id

@categories ["spam", "malware", "harassment", "copyright", "other"]
@statuses ["open", "actioned", "dismissed"]

schema "reports" do
field :paste_id, :binary_id
field :reporter_user_id, :binary_id
field :category, :string
field :notes, :string
field :status, :string, default: "open"
field :resolution_reason, :string
field :resolved_by_user_id, :binary_id
field :resolved_at, :utc_datetime_usec
timestamps(type: :utc_datetime_usec)
end

def submission_changeset(report, attrs) do
report
|> cast(attrs, [:category, :notes])
|> validate_required([:paste_id, :reporter_user_id, :category, :status])
|> validate_inclusion(:category, @categories)
|> validate_length(:notes, max: 1_000)
|> unique_constraint([:paste_id, :reporter_user_id],
name: :reports_one_open_per_reporter_index,
message: "has already been reported"
)
end

def categories, do: @categories
def statuses, do: @statuses
end
40 changes: 39 additions & 1 deletion lib/textbin_web/live/ui/admin_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ defmodule TextbinWeb.UI.AdminLive do
|> assign(:lookup_form, to_form(%{"query" => ""}, as: :lookup))
|> assign(:moderation_form, to_form(%{"paste_id" => "", "reason" => ""}, as: :moderation))
|> assign(:account_action_form, to_form(%{"reason" => ""}, as: :account_action))
|> assign(:report_action_form, to_form(%{"reason" => ""}, as: :report_action))
|> assign(:lookup_performed?, false)
|> assign(:lookup, empty_lookup())
|> stream_configure(:largest_pastes, dom_id: &"largest-paste-row-#{&1.row_key}")}
Expand All @@ -40,6 +41,11 @@ defmodule TextbinWeb.UI.AdminLive do
limit: @page_size,
page: params["largest_page"]
),
{:ok, report_page} <-
Administration.list_reports(scope,
limit: @page_size,
page: params["report_page"]
),
{:ok, audit_page} <-
Administration.list_platform_audit_events(scope,
limit: @page_size
Expand All @@ -49,9 +55,11 @@ defmodule TextbinWeb.UI.AdminLive do
|> assign(:overview, overview)
|> assign(:recent_page, recent_page)
|> assign(:largest_page, largest_page)
|> assign(:report_page, report_page)
|> assign(:audit_next_cursor, audit_page.next_cursor)
|> stream(:recent_pastes, recent_page.entries, reset: true)
|> stream(:largest_pastes, largest_stream_entries(largest_page), reset: true)
|> stream(:reports, report_page.entries, reset: true)
|> stream(:platform_audit_events, audit_page.entries, reset: true)}
else
{:error, :forbidden} -> {:noreply, leave_admin(socket)}
Expand Down Expand Up @@ -98,6 +106,21 @@ defmodule TextbinWeb.UI.AdminLive do
handle_mutation_result(result, socket, account_action_message(action))
end

def handle_event(
"review_report",
%{
"report_action" => %{
"action" => action,
"report_id" => report_id,
"reason" => reason
}
},
socket
) do
result = report_action(action, socket.assigns.current_scope, report_id, reason)
handle_mutation_result(result, socket, report_action_message(action))
end

def handle_event("load_more_audit", _params, %{assigns: %{audit_next_cursor: nil}} = socket),
do: {:noreply, socket}

Expand Down Expand Up @@ -140,12 +163,24 @@ defmodule TextbinWeb.UI.AdminLive do

defp account_action(_action, _scope, _target_id, _reason), do: {:error, :not_found}

defp report_action("dismiss", scope, report_id, reason),
do: Administration.dismiss_report(scope, report_id, reason)

defp report_action("resolve", scope, report_id, reason),
do: Administration.resolve_report(scope, report_id, reason)

defp report_action(_action, _scope, _report_id, _reason), do: {:error, :not_found}

defp account_action_message("grant"), do: "Platform administrator access granted."
defp account_action_message("revoke"), do: "Platform administrator access revoked."
defp account_action_message("suspend"), do: "Account suspended and active sessions revoked."
defp account_action_message("restore"), do: "Account restored. A new login is still required."
defp account_action_message(_action), do: "Account updated."

defp report_action_message("dismiss"), do: "Report dismissed."
defp report_action_message("resolve"), do: "Report resolved."
defp report_action_message(_action), do: "Report updated."

defp handle_mutation_result({:ok, _result}, socket, message) do
{:noreply,
socket
Expand Down Expand Up @@ -245,12 +280,15 @@ defmodule TextbinWeb.UI.AdminLive do
def audit_title("platform.account.restored"), do: "Account restored"
def audit_title("platform.admin.account_deleted"), do: "Administrator account deleted"
def audit_title("platform.paste.deleted"), do: "Paste administratively removed"
def audit_title("platform.report.dismissed"), do: "Abuse report dismissed"
def audit_title("platform.report.resolved"), do: "Abuse report resolved"
def audit_title(action), do: action

def page_params(kind, page, assigns) do
%{
recent_page: if(kind == :recent, do: page, else: assigns.recent_page.page),
largest_page: if(kind == :largest, do: page, else: assigns.largest_page.page)
largest_page: if(kind == :largest, do: page, else: assigns.largest_page.page),
report_page: if(kind == :report, do: page, else: assigns.report_page.page)
}
end

Expand Down
Loading
Loading