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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
13.1 KiB · text
History
b868907