Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196defmodule GitSync.Tangled.Client do @moduledoc """ Tangled knots speak plain git and nothing else, so a Tangled source is driven entirely by its timer. The repository list does not come from a knot at all: every repo an account owns is a public atproto record in its PDS, so listing needs no credential. """
@behaviour GitSync.Forge
alias GitSync.Connection alias GitSync.Tangled.Identity
@collection "sh.tangled.repo" @knots "sh.tangled.knot" @page_size 100 @max_pages 20
@doc """ The repositories the account owns, wherever they are knotted. Repos it only collaborates on are not here: those records live in the owner's PDS, and finding them would need an index the appview does not publish. """ @impl GitSync.Forge def list_repos(%Connection{did: nil}), do: {:error, "Save the Tangled account again to list its repositories"}
def list_repos(%Connection{} = connection) do case records(connection, nil, 1, []) do {:ok, records} -> {:ok, records |> Enum.filter(&routable?/1) |> Enum.map(&repo(connection, &1))}
{:error, reason} -> {:error, reason} end end
@impl GitSync.Forge def check(%Connection{did: nil}), do: {:error, "Save the Tangled account again to check it"}
def check(%Connection{} = connection) do request = GitSync.Http.request( base_url: String.trim_trailing(connection.pds_url, "/"), url: "/xrpc/com.atproto.repo.describeRepo", params: [repo: connection.did] )
case Req.get(request) do {:ok, %Req.Response{status: 200}} -> :ok
{:ok, %Req.Response{status: status}} -> {:error, "The repository server returned HTTP #{status}"}
{:error, exception} -> {:error, Exception.message(exception)} end end
@impl GitSync.Forge def clone_url(%Connection{base_url: base_url}, repo, :read) do {_host, path} = split(repo)
String.trim_trailing(base_url, "/") <> "/" <> path end
def clone_url(%Connection{base_url: base_url} = connection, repo, :write), do: push_url(connection, repo, knot_host(connection, repo) || URI.parse(base_url).host)
@doc """ Whether the appview runs `host` itself.
A knot is registered as an `sh.tangled.knot` record under whoever runs it, so a knot the appview registered is one the appview takes pushes for: Tangled's own knots sit behind a proxy that answers only HTTP under their own names. """ def appview_knot?(%Connection{base_url: base_url}, host) do case Identity.resolve(URI.parse(base_url).host) do {:ok, %{did: did, pds_url: pds_url}} -> request = GitSync.Http.request( base_url: String.trim_trailing(pds_url, "/"), url: "/xrpc/com.atproto.repo.getRecord", params: [repo: did, collection: @knots, rkey: host] )
match?({:ok, %Req.Response{status: 200}}, Req.get(request))
{:error, _reason} -> false end end
@doc """ The URL a push to `repo` goes to when `ssh_host` is the host that answers for its knot. """ def push_url(%Connection{}, repo, ssh_host) do {_host, path} = split(repo)
"git@" <> ssh_host <> ":" <> path end
@doc """ The knot a repo lives on when it names one, and `nil` when it lives on the connection's own. The appview proxies git over HTTP for every knot, so this only ever changes where a push goes. """ def knot_host(%Connection{}, repo) do {host, _path} = split(repo)
host end
defp split(repo) do case String.split(repo, "/") do [host, owner, name] -> {host, owner <> "/" <> name} _ -> {nil, repo} end end
defp records(_connection, _cursor, page, acc) when page > @max_pages, do: {:ok, acc}
defp records(%Connection{} = connection, cursor, page, acc) do request = GitSync.Http.request( base_url: String.trim_trailing(connection.pds_url, "/"), url: "/xrpc/com.atproto.repo.listRecords", params: [repo: connection.did, collection: @collection, limit: @page_size] ++ from(cursor) )
case Req.get(request) do {:ok, %Req.Response{status: 200, body: %{"records" => records, "cursor" => cursor}}} -> records(connection, cursor, page + 1, acc ++ records)
{:ok, %Req.Response{status: 200, body: %{"records" => records}}} -> {:ok, acc ++ records}
{:ok, %Req.Response{status: status}} -> {:error, "The repository server returned HTTP #{status}"}
{:error, exception} -> {:error, Exception.message(exception)} end end
defp from(nil), do: [] defp from(cursor), do: [cursor: cursor]
defp repo(%Connection{} = connection, %{"uri" => uri} = record) do path = connection.handle <> "/" <> String.replace(uri, ~r{.*/}, "")
%{ full_name: host(record) <> "/" <> path, clone_url: String.trim_trailing(connection.base_url, "/") <> "/" <> path, private: false } end
defp routable?(record), do: reachable?(host(record))
defp host(%{"value" => %{"knot" => knot}}), do: knot |> String.split(":") |> hd()
defp reachable?(host) do cond do not String.contains?(host, ".") -> false String.ends_with?(host, [".local", ".localhost"]) -> false true -> not private_address?(host) end end
defp private_address?(host) do case :inet.parse_ipv4strict_address(String.to_charlist(host)) do {:ok, {127, _, _, _}} -> true {:ok, {10, _, _, _}} -> true {:ok, {192, 168, _, _}} -> true {:ok, {169, 254, _, _}} -> true {:ok, {172, second, _, _}} -> second in 16..31 _ -> false end end
@impl GitSync.Forge def create_webhook(%Connection{}, _repo, _url, _secret), do: {:error, :unsupported}
@impl GitSync.Forge def reconcile_webhook(%Connection{}, _repo, _webhook_id), do: {:error, :unsupported}
@impl GitSync.Forge def verify_webhook(%Connection{}, _headers, _body, _secret), do: {:error, :unsupported}
@impl GitSync.Forge def refresh(%Connection{}), do: {:error, :unsupported}end