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