266 lines
7.7 KiB
Elixir
266 lines
7.7 KiB
Elixir
defmodule WhoNeedHelp.ContentRemoval do
|
|
@moduledoc "Separate intake and review queue for electronic content-removal notices."
|
|
|
|
import Ecto.Query
|
|
|
|
alias WhoNeedHelp.Accounts
|
|
alias WhoNeedHelp.Accounts.{Scope, User}
|
|
alias WhoNeedHelp.ContentRemoval.{Notice, Notifier}
|
|
alias WhoNeedHelp.Pagination
|
|
alias WhoNeedHelp.Repo
|
|
alias WhoNeedHelp.Trust
|
|
alias WhoNeedHelp.Trust.RateLimiter
|
|
|
|
@access_salt "content-removal-access"
|
|
@urgent_categories [
|
|
:non_consensual_intimate_media,
|
|
:child_sexual_abuse_material,
|
|
:threat_to_life_or_safety
|
|
]
|
|
|
|
def change_notice(%Notice{} = notice, attrs \\ %{}) do
|
|
Notice.submission_changeset(notice, attrs)
|
|
end
|
|
|
|
def create_notice(scope, regime, attrs) when regime in [:general, :dsa, :take_it_down] do
|
|
user = scope_user(scope)
|
|
attrs = normalize_keys(attrs)
|
|
attrs = maybe_use_user_email(attrs, user)
|
|
category = enum_value(Notice, :category, attrs["category"])
|
|
status = if category in @urgent_categories, do: :urgent_review, else: :open
|
|
|
|
response_due_at =
|
|
if regime == :take_it_down, do: DateTime.add(DateTime.utc_now(:second), 48, :hour)
|
|
|
|
with {:ok, _limit} <-
|
|
RateLimiter.check(:content_removal_notice, rate_scope(user, attrs["contact_email"])) do
|
|
Repo.transact(fn ->
|
|
with {:ok, notice} <-
|
|
%Notice{
|
|
reference: unique_reference(),
|
|
regime: regime,
|
|
status: status,
|
|
response_due_at: response_due_at,
|
|
requester_id: user && user.id,
|
|
contact_verified_at: user && DateTime.utc_now(:second)
|
|
}
|
|
|> Notice.submission_changeset(attrs)
|
|
|> Repo.insert(),
|
|
{:ok, _audit} <-
|
|
Trust.audit(
|
|
user && user.id,
|
|
"content_removal_notice.created",
|
|
"content_removal_notice",
|
|
notice.id,
|
|
%{
|
|
"category" => to_string(notice.category),
|
|
"regime" => to_string(notice.regime),
|
|
"status" => to_string(notice.status)
|
|
}
|
|
) do
|
|
{:ok, notice}
|
|
end
|
|
end)
|
|
|> notify_received()
|
|
end
|
|
end
|
|
|
|
def list_for_user(%Scope{user: %User{id: user_id}}) do
|
|
Notice
|
|
|> where([notice], notice.requester_id == ^user_id)
|
|
|> order_by([notice], desc: notice.inserted_at, desc: notice.id)
|
|
|> Repo.all()
|
|
end
|
|
|
|
def get_for_user(%Scope{user: %User{id: user_id}}, id) do
|
|
with {:ok, id} <- Ecto.UUID.cast(id),
|
|
%Notice{} = notice <- Repo.get_by(Notice, id: id, requester_id: user_id) do
|
|
{:ok, notice}
|
|
else
|
|
_ -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
def get_by_access_token(id, token) when is_binary(token) do
|
|
with {:ok, id} <- Ecto.UUID.cast(id),
|
|
{:ok, ^id} <-
|
|
Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token, max_age: 31_536_000),
|
|
%Notice{} = notice <- Repo.get(Notice, id) do
|
|
verify_contact(notice)
|
|
else
|
|
_ -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
def get_by_access_token(_id, _token), do: {:error, :not_found}
|
|
|
|
def access_token(%Notice{id: id}) do
|
|
Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @access_salt, id)
|
|
end
|
|
|
|
def status_url(%Notice{} = notice) do
|
|
token = access_token(notice)
|
|
|
|
WhoNeedHelpWeb.Endpoint.url() <>
|
|
"/legal/content-removal/#{notice.id}?token=#{URI.encode_www_form(token)}"
|
|
end
|
|
|
|
def paginate_for_staff(%Scope{user: user}, options \\ []) do
|
|
if Accounts.moderator_authorized?(user) do
|
|
limit = Pagination.limit(options)
|
|
cursor = Pagination.cursor(options)
|
|
|
|
Notice
|
|
|> maybe_regime(Keyword.get(options, :regime))
|
|
|> before(cursor)
|
|
|> order_by([notice], desc: notice.inserted_at, desc: notice.id)
|
|
|> limit(^(limit + 1))
|
|
|> preload([:requester, :reviewed_by])
|
|
|> Repo.all()
|
|
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
|
else
|
|
%Pagination.Page{}
|
|
end
|
|
end
|
|
|
|
def moderate(%Scope{user: moderator}, id, attrs) do
|
|
with {:ok, id} <- Ecto.UUID.cast(id),
|
|
true <- Accounts.moderator_authorized?(moderator) do
|
|
attrs =
|
|
attrs
|
|
|> normalize_keys()
|
|
|> Map.merge(%{
|
|
"reviewed_at" => DateTime.utc_now(:second),
|
|
"reviewed_by_id" => moderator.id
|
|
})
|
|
|
|
Repo.transact(fn ->
|
|
notice =
|
|
Notice
|
|
|> where([notice], notice.id == ^id)
|
|
|> lock("FOR UPDATE")
|
|
|> Repo.one()
|
|
|
|
if notice do
|
|
with {:ok, notice} <- notice |> Notice.moderation_changeset(attrs) |> Repo.update(),
|
|
{:ok, _audit} <-
|
|
Trust.audit(
|
|
moderator.id,
|
|
"content_removal_notice.moderated",
|
|
"content_removal_notice",
|
|
notice.id,
|
|
%{
|
|
"status" => to_string(notice.status)
|
|
}
|
|
) do
|
|
{:ok, notice}
|
|
end
|
|
else
|
|
{:error, :not_found}
|
|
end
|
|
end)
|
|
|> notify_decision()
|
|
else
|
|
false -> {:error, :forbidden}
|
|
_ -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
defp notify_received({:ok, %Notice{contact_email: email} = notice}) when email in [nil, ""],
|
|
do: notify_operator(notice)
|
|
|
|
defp notify_received({:ok, notice}) do
|
|
result =
|
|
case Notifier.deliver_received(notice, status_url(notice)) do
|
|
{:ok, _metadata} ->
|
|
notice
|
|
|> Ecto.Changeset.change(acknowledgement_sent_at: DateTime.utc_now(:second))
|
|
|> Repo.update()
|
|
|
|
_error ->
|
|
{:ok, notice}
|
|
end
|
|
|
|
case result do
|
|
{:ok, current} -> notify_operator(current)
|
|
error -> error
|
|
end
|
|
end
|
|
|
|
defp notify_received(result), do: result
|
|
|
|
defp notify_operator(notice) do
|
|
_ = Notifier.deliver_operator_alert(notice)
|
|
{:ok, notice}
|
|
end
|
|
|
|
defp notify_decision({:ok, %Notice{contact_verified_at: nil} = notice}), do: {:ok, notice}
|
|
|
|
defp notify_decision({:ok, notice}) do
|
|
case Notifier.deliver_decision(notice, status_url(notice)) do
|
|
{:ok, _metadata} ->
|
|
notice
|
|
|> Ecto.Changeset.change(decision_sent_at: DateTime.utc_now(:second))
|
|
|> Repo.update()
|
|
|
|
_error ->
|
|
{:ok, notice}
|
|
end
|
|
end
|
|
|
|
defp notify_decision(result), do: result
|
|
|
|
defp verify_contact(%Notice{contact_verified_at: nil} = notice) do
|
|
notice
|
|
|> Ecto.Changeset.change(contact_verified_at: DateTime.utc_now(:second))
|
|
|> Repo.update()
|
|
end
|
|
|
|
defp verify_contact(%Notice{} = notice), do: {:ok, notice}
|
|
|
|
defp before(query, nil), do: query
|
|
|
|
defp before(query, {inserted_at, id}) do
|
|
where(
|
|
query,
|
|
[notice],
|
|
notice.inserted_at < ^inserted_at or
|
|
(notice.inserted_at == ^inserted_at and notice.id < ^id)
|
|
)
|
|
end
|
|
|
|
defp maybe_regime(query, nil), do: query
|
|
defp maybe_regime(query, regime), do: where(query, [notice], notice.regime == ^regime)
|
|
|
|
defp maybe_use_user_email(attrs, %User{email: email}) do
|
|
Map.put(attrs, "contact_email", email)
|
|
end
|
|
|
|
defp maybe_use_user_email(attrs, nil), do: attrs
|
|
|
|
defp rate_scope(%User{id: id}, _email), do: "user:#{id}"
|
|
|
|
defp rate_scope(nil, email) when is_binary(email),
|
|
do: "email:#{String.downcase(String.trim(email))}"
|
|
|
|
defp rate_scope(nil, _email), do: "missing-email"
|
|
|
|
defp scope_user(%Scope{user: %User{} = user}), do: user
|
|
defp scope_user(_scope), do: nil
|
|
|
|
defp enum_value(module, field, value) do
|
|
Ecto.Enum.cast_value(module, field, value)
|
|
|> case do
|
|
{:ok, enum} -> enum
|
|
:error -> nil
|
|
end
|
|
end
|
|
|
|
defp unique_reference do
|
|
suffix = 8 |> :crypto.strong_rand_bytes() |> Base.encode32(case: :upper, padding: false)
|
|
"REM-#{suffix}"
|
|
end
|
|
|
|
defp normalize_keys(attrs), do: Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
|
|
end
|