lib/openagents_web/controllers/repository_import_controller.ex

58e6347eeb72 · 9 KB

defmodule OpenAgentsWeb.RepositoryImportController do
  @moduledoc """
  Accepts and reports one-time GitHub repository copies.

  Two kinds arrive here and they are not the same claim. An **import** copies a
  repository this account owns and becomes this account's own repository. An
  **upstream mirror** (`"mirror": true`) copies a public repository this
  account does not own, names the upstream, carries its license, and refuses
  every push.

  Which one you get is never inferred. A foreign source owner without
  `"mirror": true` is still refused, and the refusal names the source owner
  and the field that would admit it, because becoming a mirror is a decision
  the caller makes rather than one this controller makes for them.
  """

  use OpenAgentsWeb, :controller

  alias OpenAgents.Repositories
  alias OpenAgents.Repositories.GitHubProjection
  alias OpenAgentsWeb.{RepositoryImportJSON, RepositoryJSON}

  def create_user(conn, params) do
    with {:ok, idempotency_key} <- idempotency_key(conn),
         {:ok, full_name} <- source_repository(params),
         {:ok, mirror?} <- mirror_requested(params),
         {:ok, source, github_repository} <-
           GitHubProjection.import_source(conn.assigns.current_user, full_name),
         {:ok, attrs} <- import_attrs(params, github_repository),
         {:ok, repository, repository_import, replay_state} <-
           create_user_copy(
             mirror?,
             conn.assigns.current_user,
             source,
             attrs,
             idempotency_key
           ) do
      render_import(conn, repository, repository_import, replay_state)
    else
      {:error, reason} -> render_error(conn, reason, params)
    end
  end

  defp create_user_copy(true, user, source, attrs, idempotency_key),
    do: Repositories.create_user_mirror(user, source, attrs, idempotency_key)

  defp create_user_copy(false, user, source, attrs, idempotency_key),
    do: Repositories.create_user_import(user, source, attrs, idempotency_key)

  defp create_organization_copy(true, user, namespace, source, attrs, idempotency_key),
    do: Repositories.create_organization_mirror(user, namespace, source, attrs, idempotency_key)

  defp create_organization_copy(false, user, namespace, source, attrs, idempotency_key),
    do: Repositories.create_organization_import(user, namespace, source, attrs, idempotency_key)

  defp mirror_requested(%{"mirror" => mirror}) when is_boolean(mirror), do: {:ok, mirror}
  defp mirror_requested(%{"mirror" => _invalid}), do: {:error, :invalid_import}
  defp mirror_requested(_params), do: {:ok, false}

  def create_organization(conn, %{"org" => org} = params) do
    with {:ok, idempotency_key} <- idempotency_key(conn),
         {:ok, namespace} <-
           GitHubProjection.authorized_organization(conn.assigns.current_user, org),
         {:ok, full_name} <- source_repository(params),
         {:ok, mirror?} <- mirror_requested(params),
         {:ok, source, github_repository} <-
           GitHubProjection.import_source(conn.assigns.current_user, full_name),
         {:ok, attrs} <- import_attrs(params, github_repository),
         {:ok, repository, repository_import, replay_state} <-
           create_organization_copy(
             mirror?,
             conn.assigns.current_user,
             namespace,
             source,
             attrs,
             idempotency_key
           ) do
      render_import(conn, repository, repository_import, replay_state)
    else
      {:error, reason} -> render_error(conn, reason, params)
    end
  end

  def show(conn, %{"id" => id}) do
    repository_import = Repositories.get_import_for_user!(id, conn.assigns.current_user)
    repository = repository_import.repository
    role = Repositories.membership_role(repository, conn.assigns.current_user)

    json(conn, %{
      "repository" =>
        RepositoryJSON.repository(
          repository,
          RepositoryJSON.permissions(repository, role),
          base_url(conn)
        ),
      "import" => RepositoryImportJSON.import(repository_import)
    })
  rescue
    Ecto.NoResultsError -> render_error(conn, :not_found, %{})
  end

  defp render_import(conn, repository, repository_import, replay_state) do
    role = Repositories.membership_role(repository, conn.assigns.current_user)
    status = if repository.lifecycle_state == "ready", do: :created, else: :accepted

    repository_body =
      RepositoryJSON.repository(
        repository,
        RepositoryJSON.permissions(repository, role),
        base_url(conn)
      )

    conn
    |> put_status(status)
    |> put_resp_header("cache-control", "no-store")
    |> json(
      repository_body
      |> Map.put("import", RepositoryImportJSON.import(repository_import))
      |> Map.put("replayed", replay_state == :replayed)
    )
  end

  defp source_repository(%{
         "source" => %{"provider" => "github", "repository" => full_name}
       })
       when is_binary(full_name),
       do: {:ok, full_name}

  defp source_repository(_params), do: {:error, :invalid_import}

  defp import_attrs(params, github_repository) do
    private? = Map.get(params, "private", github_repository["private"])
    name = params["name"] || github_repository["name"]

    if is_boolean(private?) do
      {:ok,
       %{
         name: name,
         description: github_repository["description"],
         visibility: if(private?, do: "private", else: "public"),
         default_branch: github_repository["default_branch"]
       }}
    else
      {:error, :invalid_import}
    end
  end

  defp idempotency_key(conn) do
    case get_req_header(conn, "idempotency-key") do
      [key] when byte_size(key) in 1..200 ->
        if String.contains?(key, ["\r", "\n", "\0"]),
          do: {:error, :invalid_idempotency_key},
          else: {:ok, key}

      _invalid ->
        {:error, :invalid_idempotency_key}
    end
  end

  # The refusal used to say "Source namespace is not eligible", which names
  # nothing: the caller cannot tell whether the destination namespace or the
  # source owner failed, and the destination is usually their own account. The
  # source owner is what failed, so the source owner is what the message says,
  # along with the one field that admits a public repository owned by someone
  # else.
  defp render_error(conn, :source_namespace_mismatch, params) do
    error(
      conn,
      :forbidden,
      "source_namespace_mismatch",
      "The source repository #{source_name(params)} is owned by another GitHub account, " <>
        "so it cannot be imported as your own repository. " <>
        "If it is public, send \"mirror\": true to bring it in as an upstream mirror, " <>
        "which names the upstream, carries its license, and accepts no pushes.",
      %{"source" => source_name(params), "destination" => "eligible"}
    )
  end

  defp render_error(conn, :source_repository_not_public, params),
    do:
      error(
        conn,
        :forbidden,
        "source_repository_not_public",
        "The source repository #{source_name(params)} is not public, " <>
          "so it cannot be brought in as an upstream mirror.",
        %{"source" => source_name(params)}
      )

  defp render_error(conn, :not_found, _params),
    do: error(conn, :not_found, "not_found", "Repository import not found")

  defp render_error(conn, :namespace_not_allowed, _params),
    do: error(conn, :forbidden, "namespace_not_allowed", "Namespace is not eligible")

  defp render_error(conn, :github_connection_required, _params),
    do: error(conn, :forbidden, "github_connection_required", "Connect GitHub before importing")

  defp render_error(conn, :github_scope_required, _params),
    do: error(conn, :forbidden, "github_scope_required", "Reconnect GitHub with required access")

  defp render_error(conn, :source_repository_not_accessible, _params),
    do: error(conn, :forbidden, "source_repository_not_accessible", "Source is not accessible")

  defp render_error(conn, :idempotency_conflict, _params),
    do: error(conn, :conflict, "idempotency_conflict", "The idempotency key is already in use")

  defp render_error(conn, :repository_quota_exceeded, _params),
    do:
      error(
        conn,
        :unprocessable_entity,
        "repository_quota_exceeded",
        "The namespace repository quota is exhausted"
      )

  defp render_error(conn, :invalid_idempotency_key, _params),
    do: error(conn, :bad_request, "invalid_idempotency_key", "Provide one Idempotency-Key header")

  defp render_error(conn, %Ecto.Changeset{}, _params),
    do: error(conn, :unprocessable_entity, "invalid_import", "Repository import is invalid")

  defp render_error(conn, reason, _params)
       when reason in [
              :github_unavailable,
              :github_request_failed,
              :github_pagination_limit_exceeded
            ],
       do: error(conn, :service_unavailable, "github_unavailable", "GitHub is unavailable")

  defp render_error(conn, _reason, _params),
    do: error(conn, :unprocessable_entity, "invalid_import", "Repository import is invalid")

  defp source_name(%{"source" => %{"repository" => full_name}}) when is_binary(full_name),
    do: bounded_source_name(full_name)

  defp source_name(_params), do: "requested by this call"

  defp bounded_source_name(full_name) do
    full_name
    |> String.replace(~r/[^A-Za-z0-9._\/-]/, "")
    |> String.slice(0, 140)
  end

  defp error(conn, status, code, message, failed \\ nil) do
    body = %{
      "code" => code,
      "message" => message,
      "request_id" => List.first(get_resp_header(conn, "x-request-id"))
    }

    conn
    |> put_status(status)
    |> put_resp_header("cache-control", "no-store")
    |> json(if failed, do: Map.put(body, "failed", failed), else: body)
  end

  defp base_url(conn) do
    URI.to_string(%URI{scheme: Atom.to_string(conn.scheme), host: conn.host, port: conn.port})
  end
end