lib/openagents_web/controllers/data_controller.ex

main at 58e6347eeb72 · 5 KB

defmodule OpenAgentsWeb.DataController do
  use OpenAgentsWeb, :controller

  alias OpenAgents.DataRights.{AccountExport, Age}
  alias OpenAgents.{Conversations, DataRights}

  def show(conn, _params) do
    with {:ok, user, owner, conversation} <- scope(conn),
         {:ok, export} <- DataRights.export(user, owner, conversation),
         {:ok, body} <- Jason.encode(export) do
      conn
      |> put_resp_content_type("application/json")
      |> put_resp_header(
        "content-disposition",
        ~s(attachment; filename="sarah-account-data.json")
      )
      |> put_resp_header("cache-control", "no-store")
      |> send_resp(:ok, body)
    else
      _unavailable -> send_resp(conn, :not_found, "Data export is unavailable.")
    end
  end

  # The same owner scope as `show/2`: the ATIF trajectory is a second bounded
  # projection of the same account-owned graph, never a wider one.
  def export_atif(conn, _params) do
    with {:ok, user, owner, conversation} <- scope(conn),
         {:ok, export} <- OpenAgents.DataRights.AtifExport.build(user, owner, conversation),
         {:ok, body} <- Jason.encode(export) do
      conn
      |> put_resp_content_type("application/json")
      |> put_resp_header(
        "content-disposition",
        ~s(attachment; filename="sarah-conversation-#{conversation.id}-atif.json")
      )
      |> put_resp_header("cache-control", "no-store")
      |> send_resp(:ok, body)
    else
      _unavailable -> send_resp(conn, :not_found, "Trajectory export is unavailable.")
    end
  end

  @doc """
  The account-scoped export of forge-owned and forum-owned records.

  Scoped to the account rather than to a conversation, so it answers for an
  account that has never opened chat. `AccountExport.build/1` takes the
  authenticated account and nothing else, so no parameter can widen it to
  another account's records.
  """
  # The `recipient` parameter is an `age1…` public key the account generated
  # itself. Supplying one costs the operator the ability to read the file it
  # sends; supplying nothing keeps today's plain JSON, because an export
  # nobody can open is not portability either. Neither branch widens what the
  # document contains: `AccountExport.build/1` still takes the account and
  # nothing else (#178, EXIT-001).
  def export_account(conn, params) do
    with %{status: "active"} = user <- conn.assigns.current_user,
         {:ok, recipient} <- recipient(params),
         {:ok, export} <- AccountExport.build(user),
         {:ok, body} <- Jason.encode(export),
         {:ok, body, extension, type} <- encrypt(body, recipient) do
      conn
      |> put_resp_content_type(type)
      |> put_resp_header(
        "content-disposition",
        ~s(attachment; filename="openagents-account-data.json#{extension}")
      )
      |> put_resp_header("cache-control", "no-store")
      |> send_resp(:ok, body)
    else
      {:error, :invalid_recipient} ->
        conn
        |> put_status(:unprocessable_entity)
        |> text("Supply an age recipient public key, the age1… value age-keygen -y prints.")

      _unavailable ->
        send_resp(conn, :not_found, "Account export is unavailable.")
    end
  end

  defp recipient(%{"recipient" => recipient}) when is_binary(recipient) do
    case String.trim(recipient) do
      "" -> {:ok, :none}
      trimmed -> Age.parse_recipient(trimmed)
    end
  end

  defp recipient(_params), do: {:ok, :none}

  defp encrypt(body, :none), do: {:ok, body, "", "application/json"}

  defp encrypt(body, recipient) do
    with {:ok, sealed} <- Age.encrypt(body, recipient) do
      {:ok, sealed, ".age", "application/octet-stream"}
    end
  end

  def delete(conn, %{"privacy" => %{"confirmation" => "DELETE MY SARAH DATA"}}) do
    with {:ok, user, owner, conversation} <- scope(conn),
         {:ok, :deleted} <- DataRights.delete(user, owner, conversation) do
      conn
      |> redirect(to: ~p"/sarah")
    else
      {:error, :text_turn_in_progress} ->
        conn |> put_status(:conflict) |> text("Stop Sarah's typed response before deletion.")

      {:error, :voice_session_in_progress} ->
        conn |> put_status(:conflict) |> text("End voice before deletion.")

      _unavailable ->
        send_resp(conn, :not_found, "Account data is unavailable.")
    end
  end

  def delete(conn, _params) do
    conn
    |> put_status(:unprocessable_entity)
    |> text("Type DELETE MY SARAH DATA exactly to confirm deletion.")
  end

  def reset(conn, _params) do
    if DataRights.reset_enabled?() do
      with {:ok, user, owner, conversation} <- scope(conn),
           {:ok, :deleted} <- DataRights.delete(user, owner, conversation) do
        redirect(conn, to: ~p"/sarah")
      else
        {:error, :text_turn_in_progress} ->
          conn |> put_status(:conflict) |> text("Stop Sarah's typed response before the reset.")

        {:error, :voice_session_in_progress} ->
          conn |> put_status(:conflict) |> text("End voice before the reset.")

        _unavailable ->
          send_resp(conn, :not_found, "Account data is unavailable.")
      end
    else
      send_resp(conn, :not_found, "Not found.")
    end
  end

  defp scope(conn) do
    with %{status: "active"} = user <- conn.assigns.current_user,
         conversation when not is_nil(conversation) <-
           Conversations.get_conversation_for_user(user) do
      {:ok, user, Conversations.get_conversation_owner!(conversation), conversation}
    else
      _missing -> {:error, :not_found}
    end
  end
end