tarakan/lib/tarakan/github/http_client.ex
Maxfield Luke af6077b9c3
All checks were successful
CI and deploy / Test (push) Successful in 4m52s
CI and deploy / Deploy production (push) Successful in 23s
Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at
https://git.elektrine.com/elektrine/tarakan.
2026-07-29 04:43:40 -04:00

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