13.1 KiB · text History b868907
defmodule GitGudWeb.PackageRegistryController do
  @moduledoc """
  The single HTTP entry point for every non-OCI package registry.

      /pkg/:format/:owner/:name/*rest

  This controller does the four things that are the same regardless of
  format — resolve the repository, authenticate, authorize, and turn an
  adapter's return value into a response — and hands `*rest` to the
  `GitGud.Packages.Format` implementation for the rest.

  Reads (`GET`/`HEAD`) go to `index/2`, everything else to `store/3`;
  Cargo's yank is a `DELETE` and unyank a `PUT`, so "write" is the
  useful split rather than "upload".

  A 401 carries a Basic challenge. `pip` and `apt` retry against it,
  which is what makes a private index usable from a plain URL; `mix`
  and `cargo` ignore it and just fail, which is also correct — they
  were always going to send a token or nothing.
  """

  use GitGudWeb, :controller

  alias GitGud.Packages
  alias GitGud.Packages.Auth
  alias GitGud.Packages.Format
  alias GitGud.Packages.PackageFile
  alias GitGud.Repositories
  alias GitGudWeb.ErrorMessage

  # Upload bodies we read ourselves. Well above a fat manylinux wheel;
  # anything larger belongs in LFS or the OCI registry.
  @max_upload_bytes 200_000_000

  def index(conn, params), do: dispatch(conn, params, :read)
  def store(conn, params), do: dispatch(conn, params, :write)

  @doc """
  The namespace's public signing key for a format:

      GET /pkg/_keys/:format/:owner

  Signing keys belong to the namespace, so this is the URL to pin —
  one `signed-by=` keyring entry, or one `--public-key`, covering every
  repository the organization publishes. The per-repository key paths
  still work and serve the same bytes; this one exists so the
  instructions can say "once per organization" and mean it.

  Unauthenticated, and deliberately so: a public key is not a secret,
  and apt and mix both need it before any credential is configured —
  pinning the key is literally the first line of the setup, before any
  `Release` has been fetched.

  Which is also why it generates on demand rather than 404ing until
  something has been signed: otherwise the first line of the
  instructions fails for a namespace that hasn't served a `Release`
  yet. A handle that doesn't resolve gets a 404 and no key, so probing
  creates nothing; a handle that does resolve is already discoverable
  through `/orgs` and `/u/:handle`, and a public key tells an attacker
  nothing further.
  """
  # F-Droid pins a repository by the SHA-256 of its signing
  # certificate, not by a public key — a client has no use for the PEM
  # every other format publishes here. So this serves the fingerprint,
  # which is the thing that actually goes in the repo URL.
  def owner_key(conn, %{"format" => "fdroid", "owner" => owner}) do
    with {:ok, namespace} <- Repositories.resolve_namespace(owner),
         {:ok, fingerprint} <- GitGud.Packages.Formats.Fdroid.fingerprint(namespace) do
      conn
      |> put_resp_content_type_raw("text/plain; charset=utf-8")
      |> send_resp(200, fingerprint)
    else
      _ -> send_error(conn, "fdroid", 404, "no signing key published for #{owner}")
    end
  end

  def owner_key(conn, %{"format" => format, "owner" => owner}) do
    with {:ok, _mod} <- adapter(format),
         {:ok, namespace} <- Repositories.resolve_namespace(owner),
         {:ok, %{public_pem: key}} <- Packages.signing_key(namespace, format) do
      conn
      |> put_resp_content_type_raw(key_content_type(format))
      |> send_resp(200, key)
    else
      _ -> send_error(conn, format, 404, "no signing key published for #{owner}")
    end
  end

  # Both sign with OpenPGP, and both point a client at this URL —
  # `signed-by=` in a sources list, `gpgkey=` in a .repo file.
  defp key_content_type(format) when format in ~w(deb rpm), do: "application/pgp-keys"
  defp key_content_type(_format), do: "text/plain; charset=utf-8"

  # `_all` in the repository slot means the whole namespace. It can
  # never be a repository — a repository name must start with an
  # alphanumeric — so the two share a URL space without ambiguity.
  defp dispatch(conn, %{"format" => format, "owner" => owner, "name" => "_all"} = params, action) do
    with {:ok, mod} <- adapter(format),
         # `function_exported?/3` answers false for a module that
         # simply hasn't been loaded yet, so the ensure_loaded? is
         # load-bearing — without it this 404s on the first request
         # after a boot and works ever after, which is the worst
         # possible failure shape.
         true <-
           (Code.ensure_loaded?(mod) and function_exported?(mod, :namespace_index, 3)) or
             :unsupported,
         {:ok, namespace} <- Repositories.resolve_namespace(owner) do
      principal = Auth.authenticate(conn)

      # Filtered here, once, so the adapter renders what it is given
      # and makes no visibility decisions of its own. An anonymous
      # caller gets the public repositories; a token gets its own plus
      # those; a user gets what they can read. The format is passed so
      # a repository that publishes this format regardless of its
      # visibility is in the index it opted into, and no other.
      repos =
        namespace
        |> Packages.namespace_repositories()
        |> Enum.filter(&Auth.authorize(principal, &1, :read, format))

      ctx = %{
        path: List.wrap(params["rest"]),
        conn: conn,
        method: conn.method,
        trailing_slash?: String.ends_with?(conn.request_path, "/"),
        base_url: base_url(format, owner, "_all"),
        principal: principal
      }

      case action do
        :read ->
          respond(conn, format, mod.namespace_index(namespace, repos, ctx))

        # Publishing names a repository. There is no sensible one to
        # pick here, and guessing would put a package somewhere the
        # publisher didn't ask for.
        :write ->
          send_error(conn, format, 405, "publish to a repository, not to the namespace")
      end
    else
      :unsupported ->
        send_error(conn, format, 404, "#{format} has no namespace-wide index")

      # Both `adapter/1` and `resolve_namespace/1` fail as a bare
      # `:error`, and the caller gets the same 404 either way.
      :error ->
        send_error(conn, format, 404, "no such format or namespace")
    end
  end

  defp dispatch(conn, %{"format" => format, "owner" => owner, "name" => name} = params, action) do
    with {:ok, mod} <- adapter(format),
         {:ok, repo} <- fetch_repo(owner, name),
         principal = Auth.authenticate(conn),
         true <- Auth.authorize(principal, repo, action, format) do
      ctx = %{
        path: List.wrap(params["rest"]),
        conn: conn,
        method: conn.method,
        trailing_slash?: String.ends_with?(conn.request_path, "/"),
        base_url: base_url(format, owner, name),
        principal: principal
      }

      case action do
        :read -> respond(conn, format, mod.index(repo, ctx))
        :write -> write(conn, format, mod, repo, ctx)
      end
    else
      :error ->
        send_error(conn, format, 404, "unknown package format")

      # A private repository is indistinguishable from a missing one to
      # a caller who can't read it — but an anonymous caller gets a
      # challenge first, since they may simply not have sent a token.
      false ->
        if Auth.authenticate(conn) == :anonymous do
          conn
          |> put_resp_header("www-authenticate", Auth.challenge(format))
          |> send_error(format, 401, "authentication required")
        else
          send_error(conn, format, 403, "forbidden")
        end

      {:error, :not_found} ->
        send_error(conn, format, 404, "no such repository")
    end
  end

  defp write(conn, format, mod, repo, ctx) do
    case read_upload(conn) do
      {:ok, upload, conn} ->
        respond(conn, format, mod.store(repo, %{ctx | conn: conn}, upload))

      {:error, :too_large} ->
        send_error(conn, format, 413, "upload too large")
    end
  end

  # The body arrives one of two ways. Multipart (twine) has already
  # been decoded into params by `Plug.Parsers`, so hand the adapter the
  # params map. Anything else (Cargo's length-prefixed octet-stream,
  # Hex's raw tarball) passed straight through `pass: ["*/*"]` and is
  # still on the socket.
  defp read_upload(conn, acc \\ []) do
    if conn.body_params != %{} and not match?(%Plug.Conn.Unfetched{}, conn.body_params) do
      {:ok, conn.body_params, conn}
    else
      case Plug.Conn.read_body(conn,
             length: 8_000_000,
             read_timeout: GitGudWeb.Plugs.PackageUploadParser.read_timeout()
           ) do
        {:ok, chunk, conn} ->
          {:ok, IO.iodata_to_binary(Enum.reverse([chunk | acc])), conn}

        {:more, chunk, conn} ->
          acc = [chunk | acc]

          if IO.iodata_length(acc) > @max_upload_bytes,
            do: {:error, :too_large},
            else: read_upload(conn, acc)

        {:error, _} ->
          {:ok, IO.iodata_to_binary(Enum.reverse(acc)), conn}
      end
    end
  end

  # ── rendering ───────────────────────────────────────────────────────

  defp respond(conn, _format, {:ok, content_type, body}),
    do: send_body(conn, 200, content_type, body)

  defp respond(conn, _format, {:ok, content_type, body, headers}) do
    Enum.reduce(headers, conn, fn {k, v}, acc -> put_resp_header(acc, k, v) end)
    |> send_body(200, content_type, body)
  end

  defp respond(conn, _format, {:reply, status, content_type, body}),
    do: send_body(conn, status, content_type, body)

  defp respond(conn, format, {:blob, %PackageFile{} = file}), do: send_blob(conn, format, file)

  defp respond(conn, _format, {:redirect, to}),
    do: conn |> put_resp_header("location", to) |> send_resp(301, "")

  defp respond(conn, _format, :not_modified), do: send_resp(conn, 304, "")

  defp respond(conn, format, {:error, :not_found}),
    do: send_error(conn, format, 404, "not found")

  defp respond(conn, format, {:error, :not_acceptable}),
    do: send_error(conn, format, 406, "no acceptable serialisation")

  defp respond(conn, format, {:error, :file_exists}),
    do: send_error(conn, format, 409, "a file with that name is already published")

  defp respond(conn, format, {:error, {status, message}}) when is_integer(status),
    do: send_error(conn, format, status, message)

  defp respond(conn, format, {:error, reason}),
    do: send_error(conn, format, 400, error_message(reason))

  defp send_body(conn, status, content_type, body) do
    conn
    |> put_resp_content_type_raw(content_type)
    |> send_resp(status, body)
  end

  # `put_resp_content_type/2` appends `; charset=utf-8`, which corrupts
  # the exact media types PEP 691 negotiates on.
  defp put_resp_content_type_raw(conn, content_type),
    do: put_resp_header(conn, "content-type", content_type)

  defp send_blob(conn, format, %PackageFile{} = file) do
    case Packages.read_file(file) do
      {:ok, body} ->
        conn
        |> put_resp_header("etag", ~s("#{file.sha256}"))
        |> put_resp_header("content-disposition", ~s(attachment; filename="#{file.filename}"))
        # The type comes from the format's own table, never the
        # uploader; and the :raw pipeline sets no secure headers.
        |> put_resp_header("x-content-type-options", "nosniff")
        |> put_resp_header("content-security-policy", "default-src 'none'; sandbox")
        |> send_body(200, file.content_type || "application/octet-stream", body)

      {:error, _} ->
        send_error(conn, format, 404, "artifact bytes are missing from storage")
    end
  end

  # Error shape is per-ecosystem: Cargo parses `{"errors": [...]}` and
  # prints `detail` back to the user, which is worth quite a lot when a
  # publish fails. Everything else gets plain text.
  defp send_error(conn, "cargo", status, message) do
    conn
    |> put_resp_content_type_raw("application/json")
    |> send_resp(status, Jason.encode!(%{"errors" => [%{"detail" => message}]}))
  end

  defp send_error(conn, _format, status, message) do
    conn
    |> put_resp_content_type_raw("text/plain; charset=utf-8")
    |> send_resp(status, message)
  end

  defp error_message(reason) when is_binary(reason), do: reason
  defp error_message(reason) when is_atom(reason), do: to_string(reason)
  defp error_message(reason), do: ErrorMessage.describe(reason)

  # ── lookups ─────────────────────────────────────────────────────────

  defp adapter(format), do: Format.adapter(format)

  defp fetch_repo(owner, name) do
    {:ok, Repositories.get_repository_by_path!(owner, name)}
  rescue
    Ecto.NoResultsError -> {:error, :not_found}
  end

  # The registry root for this repository, absolute, no trailing slash.
  # Index documents have to emit absolute download URLs, and the host
  # a client reached us on is the only one we know works for them.
  defp base_url(format, owner, name) do
    root =
      Application.get_env(:git_gud, :package_registry_url) ||
        GitGudWeb.Endpoint.url()

    String.trim_trailing(root, "/") <> "/pkg/#{format}/#{owner}/#{name}"
  end
end