Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
509 lines
17 KiB
Elixir
509 lines
17 KiB
Elixir
defmodule Tarakan.GitHub.HTTPClient do
|
|
@moduledoc false
|
|
|
|
@behaviour Tarakan.GitHubClient
|
|
|
|
alias Tarakan.RepositoryPath
|
|
|
|
@api_root "https://api.github.com"
|
|
@max_tree_entries 2_000
|
|
@max_tree_response_bytes 2 * 1_024 * 1_024
|
|
@max_blob_bytes 512 * 1_024
|
|
@max_blob_lines 10_000
|
|
@max_blob_response_bytes 800_000
|
|
@max_metadata_response_bytes 512_000
|
|
@max_commit_response_bytes 8 * 1_024 * 1_024
|
|
@full_sha ~r/\A[0-9a-fA-F]{40}\z/
|
|
|
|
@impl true
|
|
def fetch_repository(owner, name, opts \\ []) do
|
|
with :ok <- validate_identity(owner, name) do
|
|
owner = encode_segment(owner)
|
|
name = encode_segment(name)
|
|
url = "#{@api_root}/repos/#{owner}/#{name}"
|
|
|
|
request_repository_metadata(url, conditional_headers(opts))
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def fetch_repository_by_id(github_id) when is_integer(github_id) and github_id > 0 do
|
|
request_repository_metadata("#{@api_root}/repositories/#{github_id}", [])
|
|
end
|
|
|
|
def fetch_repository_by_id(_github_id), do: {:error, :invalid_reference}
|
|
|
|
defp request_repository_metadata(url, extra_headers) do
|
|
case request_json(url, @max_metadata_response_bytes, extra_headers) do
|
|
{:ok, 200, body, resp_headers} ->
|
|
with {:ok, metadata} <- repository_metadata(body) do
|
|
{:ok, Map.put(metadata, :etag, response_etag(resp_headers))}
|
|
end
|
|
|
|
{:ok, 304, _body, _resp_headers} ->
|
|
:not_modified
|
|
|
|
{:ok, status, _body, _resp_headers} when status in [301, 302, 307, 308] ->
|
|
{:error, :moved}
|
|
|
|
{:ok, 404, _body, _resp_headers} ->
|
|
{:error, :not_found}
|
|
|
|
{:ok, status, _body, _resp_headers} when status in [403, 429] ->
|
|
{:error, :rate_limited}
|
|
|
|
{:ok, _status, _body, _resp_headers} ->
|
|
{:error, :unavailable}
|
|
|
|
{:error, :response_too_large} ->
|
|
{:error, :invalid_response}
|
|
|
|
{:error, reason} ->
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
defp conditional_headers(opts) do
|
|
case Keyword.get(opts, :etag) do
|
|
etag when is_binary(etag) and etag != "" -> [{"if-none-match", etag}]
|
|
_other -> []
|
|
end
|
|
end
|
|
|
|
defp response_etag(resp_headers) when is_map(resp_headers) do
|
|
case Map.get(resp_headers, "etag") do
|
|
[etag | _rest] when is_binary(etag) and etag != "" -> etag
|
|
_other -> nil
|
|
end
|
|
end
|
|
|
|
defp response_etag(_resp_headers), do: nil
|
|
|
|
@doc false
|
|
def repository_metadata(%{"private" => false, "visibility" => "public"} = body) do
|
|
metadata = %{
|
|
github_id: body["id"],
|
|
node_id: body["node_id"],
|
|
host: "github.com",
|
|
owner: get_in(body, ["owner", "login"]),
|
|
name: body["name"],
|
|
canonical_url: body["html_url"],
|
|
default_branch: body["default_branch"],
|
|
description: body["description"],
|
|
primary_language: body["language"],
|
|
stars_count: body["stargazers_count"] || 0,
|
|
forks_count: body["forks_count"] || 0,
|
|
archived: body["archived"] || false,
|
|
private: false,
|
|
visibility: "public",
|
|
last_synced_at: DateTime.utc_now()
|
|
}
|
|
|
|
if valid_repository_metadata?(metadata),
|
|
do: {:ok, metadata},
|
|
else: {:error, :invalid_response}
|
|
end
|
|
|
|
def repository_metadata(_body), do: {:error, :not_public}
|
|
|
|
@impl true
|
|
def fetch_commit(owner, name, sha) do
|
|
with :ok <- validate_identity(owner, name),
|
|
{:ok, sha} <- validate_sha(sha) do
|
|
owner = encode_segment(owner)
|
|
name = encode_segment(name)
|
|
|
|
case request_json(
|
|
"#{@api_root}/repos/#{owner}/#{name}/git/commits/#{sha}",
|
|
@max_commit_response_bytes
|
|
) do
|
|
{:ok, 200, body, _resp_headers} -> commit_metadata(body)
|
|
{:ok, status, _body, _resp_headers} when status in [404, 422] -> {:error, :not_found}
|
|
{:ok, status, _body, _resp_headers} when status in [403, 429] -> {:error, :rate_limited}
|
|
{:ok, _status, _body, _resp_headers} -> {:error, :unavailable}
|
|
{:error, :response_too_large} -> {:error, :invalid_response}
|
|
{:error, reason} -> {:error, reason}
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc false
|
|
def commit_metadata(body) when is_map(body) do
|
|
with {:ok, sha} <- validate_sha(body["sha"]),
|
|
{:ok, tree_sha} <- validate_sha(get_in(body, ["tree", "sha"])) do
|
|
{:ok, %{sha: sha, tree_sha: tree_sha, committed_at: parse_commit_datetime(body)}}
|
|
else
|
|
_error -> {:error, :invalid_response}
|
|
end
|
|
end
|
|
|
|
def commit_metadata(_body), do: {:error, :invalid_response}
|
|
|
|
@impl true
|
|
def fetch_branch_head(owner, name, branch) do
|
|
with :ok <- validate_identity(owner, name),
|
|
{:ok, branch} <- validate_branch(branch) do
|
|
owner = encode_segment(owner)
|
|
name = encode_segment(name)
|
|
branch = encode_segment(branch)
|
|
|
|
case request_json(
|
|
"#{@api_root}/repos/#{owner}/#{name}/branches/#{branch}",
|
|
@max_commit_response_bytes
|
|
) do
|
|
{:ok, 200, body, _resp_headers} -> branch_metadata(body)
|
|
{:ok, status, _body, _resp_headers} when status in [404, 422] -> {:error, :not_found}
|
|
{:ok, status, _body, _resp_headers} when status in [403, 429] -> {:error, :rate_limited}
|
|
{:ok, _status, _body, _resp_headers} -> {:error, :unavailable}
|
|
{:error, :response_too_large} -> {:error, :invalid_response}
|
|
{:error, reason} -> {:error, reason}
|
|
end
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def list_branches(owner, name) do
|
|
with :ok <- validate_identity(owner, name) do
|
|
owner = encode_segment(owner)
|
|
name = encode_segment(name)
|
|
# First page only - enough for typical public repos; UI can still take a raw SHA.
|
|
url = "#{@api_root}/repos/#{owner}/#{name}/branches?per_page=100"
|
|
|
|
case request_json(url, @max_metadata_response_bytes) do
|
|
{:ok, 200, body, _resp_headers} when is_list(body) ->
|
|
names =
|
|
body
|
|
|> Enum.map(fn
|
|
%{"name" => branch} when is_binary(branch) -> branch
|
|
_other -> nil
|
|
end)
|
|
|> Enum.reject(&is_nil/1)
|
|
|
|
{:ok, names}
|
|
|
|
{:ok, 200, _body, _resp_headers} ->
|
|
{:error, :invalid_response}
|
|
|
|
{:ok, status, _body, _resp_headers} when status in [404, 422] ->
|
|
{:error, :not_found}
|
|
|
|
{:ok, status, _body, _resp_headers} when status in [403, 429] ->
|
|
{:error, :rate_limited}
|
|
|
|
{:ok, _status, _body, _resp_headers} ->
|
|
{:error, :unavailable}
|
|
|
|
{:error, :response_too_large} ->
|
|
{:error, :invalid_response}
|
|
|
|
{:error, reason} ->
|
|
{:error, reason}
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc false
|
|
def branch_metadata(%{"commit" => commit}) when is_map(commit) do
|
|
commit_metadata(%{
|
|
"sha" => commit["sha"],
|
|
"tree" => get_in(commit, ["commit", "tree"]),
|
|
"committer" => get_in(commit, ["commit", "committer"])
|
|
})
|
|
end
|
|
|
|
def branch_metadata(_body), do: {:error, :invalid_response}
|
|
|
|
@impl true
|
|
def fetch_tree(owner, name, tree_sha, recursive) when is_boolean(recursive) do
|
|
with :ok <- validate_identity(owner, name),
|
|
{:ok, tree_sha} <- validate_sha(tree_sha) do
|
|
owner = encode_segment(owner)
|
|
name = encode_segment(name)
|
|
query = if recursive, do: "?recursive=1", else: ""
|
|
|
|
case request_json(
|
|
"#{@api_root}/repos/#{owner}/#{name}/git/trees/#{tree_sha}#{query}",
|
|
@max_tree_response_bytes
|
|
) do
|
|
{:ok, 200, body, _resp_headers} -> tree_metadata(body)
|
|
{:ok, status, _body, _resp_headers} when status in [404, 409, 422] -> {:error, :not_found}
|
|
{:ok, status, _body, _resp_headers} when status in [403, 429] -> {:error, :rate_limited}
|
|
{:ok, _status, _body, _resp_headers} -> {:error, :unavailable}
|
|
{:error, :response_too_large} -> {:error, :tree_too_large}
|
|
{:error, reason} -> {:error, reason}
|
|
end
|
|
end
|
|
end
|
|
|
|
def fetch_tree(_owner, _name, _tree_sha, _recursive), do: {:error, :invalid_reference}
|
|
|
|
@doc false
|
|
def tree_metadata(%{"sha" => sha, "truncated" => truncated, "tree" => entries})
|
|
when is_boolean(truncated) and is_list(entries) do
|
|
with {:ok, sha} <- validate_sha(sha),
|
|
:ok <- validate_tree_count(entries),
|
|
{:ok, entries} <- parse_tree_entries(entries) do
|
|
{:ok, %{sha: sha, truncated: truncated, entries: entries}}
|
|
else
|
|
{:error, :tree_too_large} = error -> error
|
|
_error -> {:error, :invalid_response}
|
|
end
|
|
end
|
|
|
|
def tree_metadata(_body), do: {:error, :invalid_response}
|
|
|
|
@impl true
|
|
def fetch_text_blob(owner, name, blob_sha) do
|
|
with :ok <- validate_identity(owner, name),
|
|
{:ok, blob_sha} <- validate_sha(blob_sha) do
|
|
owner = encode_segment(owner)
|
|
name = encode_segment(name)
|
|
|
|
case request_json(
|
|
"#{@api_root}/repos/#{owner}/#{name}/git/blobs/#{blob_sha}",
|
|
@max_blob_response_bytes
|
|
) do
|
|
{:ok, 200, body, _resp_headers} -> blob_metadata(body)
|
|
{:ok, status, _body, _resp_headers} when status in [404, 409, 422] -> {:error, :not_found}
|
|
{:ok, status, _body, _resp_headers} when status in [403, 429] -> {:error, :rate_limited}
|
|
{:ok, _status, _body, _resp_headers} -> {:error, :unavailable}
|
|
{:error, :response_too_large} -> {:error, :blob_too_large}
|
|
{:error, reason} -> {:error, reason}
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc false
|
|
def blob_metadata(%{
|
|
"sha" => sha,
|
|
"size" => size,
|
|
"encoding" => "base64",
|
|
"content" => encoded
|
|
})
|
|
when is_integer(size) and size >= 0 and is_binary(encoded) do
|
|
with {:ok, sha} <- validate_sha(sha),
|
|
:ok <- validate_blob_size(size),
|
|
:ok <- validate_encoded_size(encoded),
|
|
{:ok, content} <- decode_blob(encoded),
|
|
:ok <- validate_decoded_size(content, size),
|
|
:ok <- validate_text(content) do
|
|
{:ok, %{sha: sha, size: size, content: content}}
|
|
else
|
|
{:error, reason} when reason in [:blob_too_large, :binary_blob] -> {:error, reason}
|
|
_error -> {:error, :invalid_response}
|
|
end
|
|
end
|
|
|
|
def blob_metadata(_body), do: {:error, :invalid_response}
|
|
|
|
defp request_json(url, max_bytes, extra_headers \\ []) do
|
|
into = fn {:data, data}, {request, response} ->
|
|
case append_chunk(response.body, data, max_bytes) do
|
|
{:ok, body} -> {:cont, {request, %{response | body: body}}}
|
|
:too_large -> {:halt, {request, %{response | body: :response_too_large}}}
|
|
end
|
|
end
|
|
|
|
case Req.get(url,
|
|
headers: headers() ++ extra_headers,
|
|
raw: true,
|
|
redirect: false,
|
|
retry: false,
|
|
receive_timeout: 15_000,
|
|
into: into
|
|
) do
|
|
{:ok, %{body: :response_too_large}} ->
|
|
{:error, :response_too_large}
|
|
|
|
{:ok, %{status: status, body: body, headers: resp_headers}}
|
|
when is_integer(status) and status == 200 ->
|
|
with {:ok, encoded} <- collected_body(body),
|
|
{:ok, decoded} <- Jason.decode(encoded) do
|
|
{:ok, status, decoded, resp_headers}
|
|
else
|
|
_error -> {:error, :invalid_response}
|
|
end
|
|
|
|
{:ok, %{status: status, headers: resp_headers}} when is_integer(status) ->
|
|
{:ok, status, nil, resp_headers}
|
|
|
|
{:error, _exception} ->
|
|
{:error, :unavailable}
|
|
end
|
|
end
|
|
|
|
defp append_chunk("", data, max_bytes), do: append_chunk({:chunks, 0, []}, data, max_bytes)
|
|
|
|
defp append_chunk({:chunks, size, chunks}, data, max_bytes)
|
|
when is_binary(data) and size + byte_size(data) <= max_bytes do
|
|
{:ok, {:chunks, size + byte_size(data), [data | chunks]}}
|
|
end
|
|
|
|
defp append_chunk(_body, _data, _max_bytes), do: :too_large
|
|
|
|
defp collected_body(""), do: {:ok, ""}
|
|
|
|
defp collected_body({:chunks, _size, chunks}) do
|
|
{:ok, chunks |> Enum.reverse() |> IO.iodata_to_binary()}
|
|
end
|
|
|
|
defp collected_body(_body), do: {:error, :invalid_response}
|
|
|
|
defp parse_tree_entries(entries) do
|
|
Enum.reduce_while(entries, {:ok, []}, fn entry, {:ok, parsed} ->
|
|
case parse_tree_entry(entry) do
|
|
{:ok, normalized} -> {:cont, {:ok, [normalized | parsed]}}
|
|
{:error, reason} -> {:halt, {:error, reason}}
|
|
end
|
|
end)
|
|
|> case do
|
|
{:ok, parsed} -> {:ok, Enum.reverse(parsed)}
|
|
error -> error
|
|
end
|
|
end
|
|
|
|
defp parse_tree_entry(
|
|
%{
|
|
"path" => path,
|
|
"mode" => mode,
|
|
"type" => raw_type,
|
|
"sha" => sha
|
|
} = entry
|
|
)
|
|
when is_binary(mode) do
|
|
with {:ok, path} <- RepositoryPath.normalize(path),
|
|
false <- path == "",
|
|
{:ok, sha} <- validate_sha(sha),
|
|
{:ok, type} <- normalize_entry_type(raw_type, mode),
|
|
{:ok, size} <- normalize_entry_size(type, entry["size"]) do
|
|
{:ok, %{path: path, mode: mode, type: type, sha: sha, size: size}}
|
|
else
|
|
_error -> {:error, :invalid_response}
|
|
end
|
|
end
|
|
|
|
defp parse_tree_entry(_entry), do: {:error, :invalid_response}
|
|
|
|
defp normalize_entry_type("tree", "040000"), do: {:ok, "tree"}
|
|
defp normalize_entry_type("blob", "120000"), do: {:ok, "symlink"}
|
|
defp normalize_entry_type("blob", mode) when mode in ["100644", "100755"], do: {:ok, "blob"}
|
|
defp normalize_entry_type("commit", "160000"), do: {:ok, "submodule"}
|
|
defp normalize_entry_type(_type, _mode), do: {:error, :invalid_response}
|
|
|
|
defp normalize_entry_size("blob", size) when is_integer(size) and size >= 0, do: {:ok, size}
|
|
defp normalize_entry_size("symlink", size) when is_integer(size) and size >= 0, do: {:ok, size}
|
|
defp normalize_entry_size(type, nil) when type in ["tree", "submodule"], do: {:ok, nil}
|
|
defp normalize_entry_size(_type, _size), do: {:error, :invalid_response}
|
|
|
|
defp validate_tree_count(entries) do
|
|
if length(entries) <= @max_tree_entries, do: :ok, else: {:error, :tree_too_large}
|
|
end
|
|
|
|
defp validate_blob_size(size) do
|
|
if size <= @max_blob_bytes, do: :ok, else: {:error, :blob_too_large}
|
|
end
|
|
|
|
defp validate_encoded_size(encoded) do
|
|
if byte_size(encoded) <= @max_blob_response_bytes,
|
|
do: :ok,
|
|
else: {:error, :blob_too_large}
|
|
end
|
|
|
|
defp decode_blob(encoded) do
|
|
case Base.decode64(encoded, ignore: :whitespace) do
|
|
{:ok, content} -> {:ok, content}
|
|
:error -> {:error, :invalid_response}
|
|
end
|
|
end
|
|
|
|
defp validate_decoded_size(content, size) do
|
|
if byte_size(content) == size, do: :ok, else: {:error, :invalid_response}
|
|
end
|
|
|
|
defp validate_text(content) do
|
|
cond do
|
|
not String.valid?(content) ->
|
|
{:error, :binary_blob}
|
|
|
|
String.contains?(content, <<0>>) or
|
|
String.match?(content, ~r/[\x01-\x08\x0B\x0C\x0E-\x1F\x7F]/) ->
|
|
{:error, :binary_blob}
|
|
|
|
source_line_count(content) > @max_blob_lines ->
|
|
{:error, :blob_too_large}
|
|
|
|
true ->
|
|
:ok
|
|
end
|
|
end
|
|
|
|
defp source_line_count(""), do: 0
|
|
defp source_line_count(content), do: length(:binary.matches(content, "\n")) + 1
|
|
|
|
defp validate_identity(owner, name) when is_binary(owner) and is_binary(name) do
|
|
if Regex.match?(~r/\A[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?\z/, owner) and
|
|
Regex.match?(~r/\A[a-zA-Z0-9._-]{1,100}\z/, name) do
|
|
:ok
|
|
else
|
|
{:error, :invalid_reference}
|
|
end
|
|
end
|
|
|
|
defp validate_identity(_owner, _name), do: {:error, :invalid_reference}
|
|
|
|
defp validate_branch(branch) when is_binary(branch) do
|
|
if branch != "" and byte_size(branch) <= 500 and String.valid?(branch) and
|
|
not String.match?(branch, ~r/[\x00-\x1F\x7F]/) do
|
|
{:ok, branch}
|
|
else
|
|
{:error, :invalid_reference}
|
|
end
|
|
end
|
|
|
|
defp validate_branch(_branch), do: {:error, :invalid_reference}
|
|
|
|
defp validate_sha(sha) when is_binary(sha) do
|
|
if Regex.match?(@full_sha, sha),
|
|
do: {:ok, String.downcase(sha)},
|
|
else: {:error, :invalid_reference}
|
|
end
|
|
|
|
defp validate_sha(_sha), do: {:error, :invalid_reference}
|
|
|
|
defp valid_repository_metadata?(metadata) do
|
|
is_integer(metadata.github_id) and metadata.github_id > 0 and
|
|
is_binary(metadata.owner) and metadata.owner != "" and
|
|
is_binary(metadata.name) and metadata.name != "" and
|
|
is_binary(metadata.canonical_url) and metadata.canonical_url != "" and
|
|
is_binary(metadata.default_branch) and metadata.default_branch != ""
|
|
end
|
|
|
|
defp encode_segment(segment), do: URI.encode(segment, &URI.char_unreserved?/1)
|
|
|
|
defp headers do
|
|
github_config = Application.fetch_env!(:tarakan, :github)
|
|
|
|
base_headers = [
|
|
{"accept", "application/vnd.github+json"},
|
|
{"user-agent", "Tarakan/0.1 (https://tarakan.lol)"},
|
|
{"x-github-api-version", Keyword.fetch!(github_config, :api_version)}
|
|
]
|
|
|
|
case github_config[:api_token] do
|
|
token when is_binary(token) and token != "" ->
|
|
[{"authorization", "Bearer #{token}"} | base_headers]
|
|
|
|
_other ->
|
|
base_headers
|
|
end
|
|
end
|
|
|
|
defp parse_commit_datetime(body) do
|
|
with date when is_binary(date) <- get_in(body, ["committer", "date"]),
|
|
{:ok, datetime, _offset} <- DateTime.from_iso8601(date) do
|
|
datetime
|
|
else
|
|
_other -> nil
|
|
end
|
|
end
|
|
end
|