5.4 KiB · text History b868907
defmodule GitGudWeb.Api.Mobile.PackageController do
  @moduledoc """
  The repository's published packages, and how to install them.

  Read-only, and deliberately so. Publishing is something a CI job does
  with a token scoped to one repository; a phone has no build to
  publish and no business holding a credential that could.

  Three endpoints, shaped by what the screen actually needs:

    * `index` — every format with something published, each with its
      packages. One call fills the tab, because a list that needs a
      request per format is a list that renders in stages.
    * `show` — one package's versions, with the files under each and
      the one-liner that installs it.
    * `usage` — the full setup instructions for one format, fetched
      when the reader opens them rather than attached to every list.

  ## Why the instructions come from the server

  `GitGud.Packages.Usage` already renders them for the web page, and
  they are full of things only the server knows: the registry's own
  URL, the namespace a repository belongs to, whether a Hex repo is
  named after the org or the repository. A client that rebuilt those
  strings would be wrong the first time a deployment moved.
  """
  use GitGudWeb, :controller

  import GitGudWeb.Api.Mobile.Helpers

  alias GitGud.Packages
  alias GitGud.Packages.Format
  alias GitGud.Packages.Usage
  alias GitGudWeb.Api.Mobile.Auth
  alias GitGudWeb.Api.Mobile.JSON

  # Containers first — the oldest surface here — then the language
  # registries in the order they landed. Same order the web page uses,
  # so someone looking at both sees the same thing twice.
  @format_order ~w(oci pypi hex cargo deb rpm fdroid)

  def index(conn, %{"owner" => owner, "name" => name}) do
    with {:ok, user} <- Auth.require_scope(conn, "packages:read"),
         {:ok, repo} <- fetch_repo(conn, user, owner, name) do
      ctx = usage_context(repo)

      formats =
        repo
        |> Packages.list_packages()
        |> Enum.group_by(& &1.format)
        |> Enum.sort_by(fn {format, _} -> order_of(format) end)
        |> Enum.map(fn {format, packages} ->
          JSON.package_format(format, packages, ctx)
        end)

      # Every format this forge can serve, not just the ones with
      # something in them: an empty tab's only useful content is "here
      # is how you would publish", and the client can't name the
      # formats itself without carrying a list that goes stale.
      json(conn, %{"formats" => formats, "available_formats" => Format.formats()})
    else
      {:error, conn} -> conn
    end
  end

  def show(conn, %{"owner" => owner, "name" => name, "format" => format, "package" => package}) do
    with {:ok, user} <- Auth.require_scope(conn, "packages:read"),
         {:ok, repo} <- fetch_repo(conn, user, owner, name),
         {:ok, package} <- fetch_package(conn, repo, format, package) do
      json(conn, JSON.package_detail(package, usage_context(repo)))
    else
      {:error, conn} -> conn
    end
  end

  @doc """
  A package's icon, as image bytes — what a list or detail's
  `icon_url` points at. Here rather than on the web route so it
  authenticates like the rest of this API, with the bearer token.
  """
  def icon(conn, %{"owner" => owner, "name" => name, "format" => format, "package" => package}) do
    with {:ok, user} <- Auth.require_scope(conn, "packages:read"),
         {:ok, repo} <- fetch_repo(conn, user, owner, name),
         {:ok, package} <- fetch_package(conn, repo, format, package) do
      case Packages.icon(package) do
        {:ok, content_type, bytes, sha256} ->
          conn
          |> put_resp_header("etag", ~s("#{sha256}"))
          |> put_resp_header("cache-control", "private, max-age=300")
          |> put_resp_content_type(content_type, nil)
          |> send_resp(200, bytes)

        :none ->
          send_error(conn, 404, "this package has no icon")
      end
    else
      {:error, conn} -> conn
    end
  end

  @doc """
  Setup instructions for one format under this repository.

  Served even when the format has nothing published yet: "here is how
  you would publish to it" is the useful answer to an empty tab, and
  the client has no way to render it itself.
  """
  def usage(conn, %{"owner" => owner, "name" => name, "format" => format}) do
    with {:ok, user} <- Auth.require_scope(conn, "packages:read"),
         {:ok, repo} <- fetch_repo(conn, user, owner, name),
         {:ok, _mod} <- known_format(conn, format) do
      json(conn, JSON.package_usage(format, usage_context(repo)))
    else
      {:error, conn} -> conn
    end
  end

  defp usage_context(repo),
    do: Usage.context(repo, Application.get_env(:git_gud, :registry_host))

  defp fetch_package(conn, repo, format, name) do
    case Packages.get_package(repo, format, name) do
      nil -> {:error, send_error(conn, 404, "no #{format} package '#{name}' in this repository")}
      package -> {:ok, package}
    end
  end

  # `oci` has no adapter — zot serves it — but it is a format this
  # forge publishes and has instructions for, so it is known here even
  # though `Format.adapter/1` doesn't answer for it.
  defp known_format(conn, format) do
    if format in Format.formats() do
      {:ok, format}
    else
      {:error, send_error(conn, 404, "unknown package format '#{format}'")}
    end
  end

  defp order_of(format) do
    case Enum.find_index(@format_order, &(&1 == format)) do
      nil -> length(@format_order)
      index -> index
    end
  end
end