Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
856 lines
28 KiB
Elixir
856 lines
28 KiB
Elixir
defmodule TarakanWeb.RepositoryCodeLive do
|
|
use TarakanWeb, :live_view
|
|
|
|
alias Tarakan.Repositories
|
|
alias Tarakan.Repositories.Repository
|
|
alias Tarakan.RepositoryCode
|
|
alias Tarakan.RepositoryCode.{File, Tree}
|
|
alias Tarakan.RepositoryPath
|
|
alias Tarakan.Scans
|
|
alias TarakanWeb.RepositoryPaths
|
|
|
|
@commit_sha_pattern ~r/^[0-9a-f]{40}$/
|
|
@line_range_pattern ~r/^([1-9][0-9]{0,8})(?:-([1-9][0-9]{0,8}))?$/
|
|
@max_rendered_lines 10_000
|
|
@max_selected_line 1_000_000
|
|
@max_selected_span 1_000
|
|
|
|
@impl true
|
|
def mount(params, _session, socket) do
|
|
{action, params} = normalize_route_params(socket.assigns.live_action, params)
|
|
socket = assign(socket, :live_action, action)
|
|
|
|
case source_from_params(action, params, socket) do
|
|
{:ok, source} ->
|
|
repository = source.repository
|
|
|
|
if connected?(socket) do
|
|
Repositories.subscribe()
|
|
Scans.subscribe(repository.id)
|
|
end
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, "Code · #{repository.owner}/#{repository.name}")
|
|
|> assign(:repository, repository)
|
|
|> assign(:clone_urls, RepositoryPaths.clone_urls(repository))
|
|
|> assign(:finding, source.finding)
|
|
|> assign(:finding_ref, source.finding && source.finding.public_id)
|
|
|> assign(:source_commit_sha, source.commit_sha)
|
|
|> assign(:source_path, source.path)
|
|
|> assign(:source_line_range, source.line_range)
|
|
|> assign(:commit_sha, nil)
|
|
|> assign(:path, "")
|
|
|> assign(:path_segments, [])
|
|
|> assign(:line_range, nil)
|
|
|> assign(:line_range_invalid?, false)
|
|
|> assign(:view_state, :loading)
|
|
|> assign(:browser_kind, nil)
|
|
|> assign(:tree, nil)
|
|
|> assign(:file, nil)
|
|
|> assign(:entry_count, 0)
|
|
|> assign(:visible_finding_count, 0)
|
|
|> assign(:line_count, 0)
|
|
|> assign(:line_range_outside_file?, false)
|
|
|> assign(:suspicious_controls?, false)
|
|
|> assign(:error_kind, nil)
|
|
|> assign(:request_id, nil)
|
|
|> assign(:branch_options, [])
|
|
|> assign(:selected_branch, repository.default_branch)
|
|
|> maybe_load_branch_options(action)
|
|
|> stream_configure(:entries, dom_id: &entry_dom_id/1)
|
|
|> stream_configure(:lines, dom_id: &line_dom_id/1)
|
|
|> stream(:entries, [])
|
|
|> stream(:lines, [])}
|
|
|
|
{:error, :not_found} ->
|
|
raise Ecto.NoResultsError, queryable: Repository
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(params, _uri, socket) do
|
|
{action, params} = normalize_route_params(socket.assigns.live_action, params)
|
|
|
|
socket =
|
|
case action do
|
|
:entry -> prepare_entry_request(socket)
|
|
:finding -> prepare_finding_request(socket)
|
|
:show -> prepare_code_request(socket, params)
|
|
end
|
|
|
|
{:noreply, maybe_start_request(assign(socket, :live_action, action))}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("retry", _params, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:view_state, :loading)
|
|
|> assign(:error_kind, nil)
|
|
|> maybe_start_request()}
|
|
end
|
|
|
|
# Switch the code browser to another branch tip (still commit-pinned under the hood).
|
|
def handle_event("select_branch", %{"branch" => branch}, socket) do
|
|
if socket.assigns.live_action == :finding do
|
|
{:noreply, put_flash(socket, :error, "Finding context is pinned to its report commit.")}
|
|
else
|
|
repository = socket.assigns.repository
|
|
branch = String.trim(to_string(branch || ""))
|
|
|
|
case RepositoryCode.resolve_branch_commit(repository, branch) do
|
|
{:ok, commit_sha} ->
|
|
path =
|
|
if is_binary(socket.assigns.path) and socket.assigns.path != "" do
|
|
split_path(socket.assigns.path)
|
|
else
|
|
[]
|
|
end
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:selected_branch, branch)
|
|
|> push_navigate(to: code_path(repository, commit_sha, path))}
|
|
|
|
{:error, _reason} ->
|
|
{:noreply,
|
|
put_flash(socket, :error, "Could not resolve branch #{inspect(branch)} to a commit.")}
|
|
end
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_async(:browse, {:ok, {request_id, result}}, socket) do
|
|
if request_id == socket.assigns.request_id do
|
|
case reauthorize_view(socket) do
|
|
{:ok, authorized_socket} ->
|
|
{:noreply, apply_browse_result(authorized_socket, result)}
|
|
|
|
{:error, :not_found} ->
|
|
{:noreply, evict_source(socket)}
|
|
end
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
def handle_async(:browse, {:exit, _reason}, socket) do
|
|
{:noreply, assign_error(socket, :unavailable)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(
|
|
{:repository_record_updated, %Repository{id: repository_id}},
|
|
%{assigns: %{repository: %Repository{id: repository_id}}} = socket
|
|
) do
|
|
case reauthorize_view(socket) do
|
|
{:ok, authorized_socket} -> {:noreply, refresh_finding_badges(authorized_socket)}
|
|
{:error, :not_found} -> {:noreply, evict_source(socket)}
|
|
end
|
|
end
|
|
|
|
def handle_info({event, %Repository{}}, socket)
|
|
when event in [:repository_registered, :repository_record_updated] do
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_info(
|
|
{:scan_updated, %{id: scan_id}},
|
|
%{assigns: %{live_action: :finding, finding: %{scan_id: scan_id}}} = socket
|
|
) do
|
|
case reauthorize_view(socket) do
|
|
{:ok, authorized_socket} -> {:noreply, authorized_socket}
|
|
{:error, :not_found} -> {:noreply, evict_source(socket)}
|
|
end
|
|
end
|
|
|
|
def handle_info({event, _scan}, socket) when event in [:scan_submitted, :scan_updated],
|
|
do: {:noreply, refresh_finding_badges(socket)}
|
|
|
|
defp source_from_params(:finding, %{"finding_ref" => finding_ref}, socket) do
|
|
with {:ok, {scan, finding}} <- Scans.get_finding(socket.assigns.current_scope, finding_ref),
|
|
%Repository{} = repository <-
|
|
Repositories.get_visible_repository(
|
|
scan.repository.host,
|
|
scan.repository.owner,
|
|
scan.repository.name,
|
|
socket.assigns.current_scope
|
|
) do
|
|
{:ok,
|
|
%{
|
|
repository: repository,
|
|
finding: finding,
|
|
commit_sha: scan.commit_sha,
|
|
path: finding.file_path,
|
|
line_range: finding_line_range(finding)
|
|
}}
|
|
end
|
|
end
|
|
|
|
defp source_from_params(_action, %{"host" => slug, "owner" => owner, "name" => name}, socket) do
|
|
with {:ok, host} <- Tarakan.Hosts.host_for_slug(slug),
|
|
%Repository{} = repository <-
|
|
Repositories.get_visible_repository(host, owner, name, socket.assigns.current_scope) do
|
|
{:ok,
|
|
%{
|
|
repository: repository,
|
|
finding: nil,
|
|
commit_sha: nil,
|
|
path: nil,
|
|
line_range: nil
|
|
}}
|
|
else
|
|
_not_visible -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
defp source_from_params(_action, _params, _socket), do: {:error, :not_found}
|
|
|
|
# Resolves the bare GitHub-style routes (/owner/name...), which carry no
|
|
# :host segment. A handle-like first segment is a Tarakan-hosted
|
|
# repository. A host-like first segment means the bare pattern's literal
|
|
# ("code") swallowed a remote path - a repository literally named "code" -
|
|
# so shift the params: /github.com/rails/code[/code[/sha[/*path]]].
|
|
# The security tab of such a repository has no reachable URL form; those
|
|
# requests fall through to :not_found.
|
|
defp normalize_route_params(action, %{"owner" => owner, "name" => name} = params)
|
|
when not is_map_key(params, "host") do
|
|
if Tarakan.Hosts.host_segment?(owner) do
|
|
remote = %{"host" => owner, "owner" => name, "name" => "code"}
|
|
|
|
case {action, params["commit_sha"], params["path"]} do
|
|
{:code_entry, _sha, _path} ->
|
|
{:entry, remote}
|
|
|
|
{:show, "code", nil} ->
|
|
{:entry, remote}
|
|
|
|
{:show, "code", [commit_sha | path]} ->
|
|
{:show, remote |> Map.put("commit_sha", commit_sha) |> Map.put("path", path)}
|
|
|
|
_other ->
|
|
{action, params}
|
|
end
|
|
else
|
|
{action_without_code_alias(action), Map.put(params, "host", Repository.hosted_host())}
|
|
end
|
|
end
|
|
|
|
defp normalize_route_params(action, params), do: {action_without_code_alias(action), params}
|
|
|
|
# /:owner/:name/code is a distinct live action only so bare-route
|
|
# reinterpretation can tell it apart from /:owner/:name; everywhere else
|
|
# it behaves exactly like :entry.
|
|
defp action_without_code_alias(:code_entry), do: :entry
|
|
defp action_without_code_alias(action), do: action
|
|
|
|
defp prepare_entry_request(socket) do
|
|
prepare_request(socket, :resolve_entry, nil, "", nil, false)
|
|
end
|
|
|
|
defp prepare_finding_request(socket) do
|
|
prepare_request(
|
|
socket,
|
|
:browse_finding,
|
|
socket.assigns.source_commit_sha,
|
|
socket.assigns.source_path,
|
|
socket.assigns.source_line_range,
|
|
false
|
|
)
|
|
end
|
|
|
|
defp prepare_code_request(socket, params) do
|
|
with {:ok, commit_sha} <- normalize_commit_sha(params["commit_sha"]),
|
|
{:ok, path} <- normalize_route_path(params["path"]),
|
|
{:ok, line_range, line_range_invalid?} <- parse_line_range(params["lines"]) do
|
|
socket
|
|
|> prepare_request(
|
|
:browse,
|
|
commit_sha,
|
|
path,
|
|
line_range,
|
|
line_range_invalid?
|
|
)
|
|
|> sync_selected_branch(commit_sha)
|
|
else
|
|
{:error, reason} -> assign_error(socket, reason)
|
|
end
|
|
end
|
|
|
|
# After a branch switch (or deep link to a tip SHA), keep the picker in sync.
|
|
defp sync_selected_branch(socket, commit_sha) do
|
|
case RepositoryCode.branch_for_commit(socket.assigns.repository, commit_sha) do
|
|
{:ok, branch} -> assign(socket, :selected_branch, branch)
|
|
_unknown -> socket
|
|
end
|
|
end
|
|
|
|
defp prepare_request(
|
|
socket,
|
|
request_kind,
|
|
commit_sha,
|
|
path,
|
|
line_range,
|
|
line_range_invalid?
|
|
) do
|
|
request_id = System.unique_integer([:positive, :monotonic])
|
|
|
|
socket
|
|
|> assign(:request_id, request_id)
|
|
|> assign(:request_kind, request_kind)
|
|
|> assign(:commit_sha, commit_sha)
|
|
|> assign(:path, path)
|
|
|> assign(:path_segments, split_path(path))
|
|
|> assign(:line_range, line_range)
|
|
|> assign(:line_range_invalid?, line_range_invalid?)
|
|
|> assign(:view_state, :loading)
|
|
|> assign(:browser_kind, nil)
|
|
|> assign(:tree, nil)
|
|
|> assign(:file, nil)
|
|
|> assign(:entry_count, 0)
|
|
|> assign(:visible_finding_count, 0)
|
|
|> assign(:line_count, 0)
|
|
|> assign(:line_range_outside_file?, false)
|
|
|> assign(:suspicious_controls?, false)
|
|
|> assign(:error_kind, nil)
|
|
|> stream(:entries, [], reset: true)
|
|
|> stream(:lines, [], reset: true)
|
|
end
|
|
|
|
defp maybe_start_request(%{assigns: %{view_state: :loading}} = socket) do
|
|
if connected?(socket) do
|
|
request_id = socket.assigns.request_id
|
|
request_kind = socket.assigns.request_kind
|
|
repository = socket.assigns.repository
|
|
commit_sha = socket.assigns.commit_sha
|
|
path = socket.assigns.path
|
|
# Per-IP browsing budgets are enforced at the HTTP layer by
|
|
# TarakanWeb.Plugs.CodeBrowserRateLimit; the reads below stay on the
|
|
# default (non-bypassed) code path.
|
|
socket
|
|
|> cancel_async(:browse)
|
|
|> start_async(:browse, fn ->
|
|
{request_id, run_request(request_kind, repository, commit_sha, path)}
|
|
end)
|
|
else
|
|
socket
|
|
end
|
|
end
|
|
|
|
defp maybe_start_request(socket), do: socket
|
|
|
|
defp run_request(:browse, repository, commit_sha, path) do
|
|
# Generic browser may open any *current* branch tip (not only default),
|
|
# but still rejects guessed historical SHAs that are not branch tips.
|
|
with :ok <- RepositoryCode.authorize_public_commit(repository, commit_sha),
|
|
{:ok, result} <- RepositoryCode.browse(repository, commit_sha, path, []) do
|
|
{:ok, result}
|
|
else
|
|
# Never show the old "busy network" page for GitHub REST quota.
|
|
{:error, :rate_limited} -> {:error, :unavailable}
|
|
{:error, reason} -> {:error, reason}
|
|
end
|
|
end
|
|
|
|
defp run_request(:browse_finding, repository, commit_sha, path) do
|
|
case RepositoryCode.browse(repository, commit_sha, path, []) do
|
|
{:error, :rate_limited} -> {:error, :unavailable}
|
|
other -> other
|
|
end
|
|
end
|
|
|
|
defp run_request(:resolve_entry, repository, _commit_sha, _path) do
|
|
with {:ok, commit_sha} <- RepositoryCode.resolve_default_commit(repository, []),
|
|
{:ok, result} <- RepositoryCode.browse(repository, commit_sha, "", []) do
|
|
{:ok, result}
|
|
else
|
|
{:error, :rate_limited} -> {:error, :unavailable}
|
|
{:error, reason} -> {:error, reason}
|
|
end
|
|
end
|
|
|
|
defp apply_browse_result(socket, {:ok, %Tree{truncated: true}}) do
|
|
assign_error(socket, :tree_truncated)
|
|
end
|
|
|
|
defp apply_browse_result(socket, {:ok, %Tree{} = tree}) do
|
|
finding_paths = visible_finding_paths(socket, tree.commit_sha)
|
|
|
|
entries =
|
|
tree.entries
|
|
|> sort_entries()
|
|
|> Enum.map(&entry_with_finding_count(&1, finding_paths))
|
|
|
|
socket
|
|
|> assign(:view_state, :ready)
|
|
|> assign(:browser_kind, :tree)
|
|
|> assign(:commit_sha, tree.commit_sha)
|
|
|> assign(:path, tree.path)
|
|
|> assign(:path_segments, split_path(tree.path))
|
|
|> assign(:tree, tree)
|
|
|> assign(:file, nil)
|
|
|> assign(:entry_count, length(entries))
|
|
|> assign(:visible_finding_count, length(finding_paths))
|
|
|> assign(:line_count, 0)
|
|
|> assign(:line_range_outside_file?, false)
|
|
|> assign(:suspicious_controls?, false)
|
|
|> assign(:error_kind, nil)
|
|
|> stream(:entries, entries, reset: true)
|
|
|> stream(:lines, [], reset: true)
|
|
end
|
|
|
|
defp apply_browse_result(socket, {:ok, %File{} = file}) do
|
|
case file_lines(file.content, socket.assigns.line_range) do
|
|
{:ok, lines} ->
|
|
line_count = length(lines)
|
|
finding_count = visible_file_finding_count(socket, file.commit_sha, file.path)
|
|
|
|
socket
|
|
|> assign(:view_state, :ready)
|
|
|> assign(:browser_kind, :file)
|
|
|> assign(:commit_sha, file.commit_sha)
|
|
|> assign(:path, file.path)
|
|
|> assign(:path_segments, split_path(file.path))
|
|
|> assign(:tree, nil)
|
|
|> assign(:file, file)
|
|
|> assign(:entry_count, 0)
|
|
|> assign(:visible_finding_count, finding_count)
|
|
|> assign(:line_count, line_count)
|
|
|> assign(
|
|
:line_range_outside_file?,
|
|
line_range_outside_file?(socket.assigns.line_range, line_count)
|
|
)
|
|
|> assign(:suspicious_controls?, suspicious_source_controls?(file.content))
|
|
|> assign(:error_kind, nil)
|
|
|> stream(:entries, [], reset: true)
|
|
|> stream(:lines, lines, reset: true)
|
|
|
|
{:error, :too_many_lines} ->
|
|
assign_error(socket, :blob_too_large)
|
|
end
|
|
end
|
|
|
|
defp apply_browse_result(socket, {:ok, commit_sha}) when is_binary(commit_sha) do
|
|
case normalize_commit_sha(commit_sha) do
|
|
{:ok, commit_sha} ->
|
|
push_navigate(socket,
|
|
to: code_path(socket.assigns.repository, commit_sha, [])
|
|
)
|
|
|
|
{:error, _reason} ->
|
|
assign_error(socket, :invalid_response)
|
|
end
|
|
end
|
|
|
|
defp apply_browse_result(socket, {:error, reason}), do: assign_error(socket, reason)
|
|
defp apply_browse_result(socket, _unexpected), do: assign_error(socket, :invalid_response)
|
|
|
|
defp assign_error(socket, reason) do
|
|
socket
|
|
|> assign(:view_state, :error)
|
|
|> assign(:browser_kind, nil)
|
|
|> assign(:tree, nil)
|
|
|> assign(:file, nil)
|
|
|> assign(:entry_count, 0)
|
|
|> assign(:visible_finding_count, 0)
|
|
|> assign(:line_count, 0)
|
|
|> assign(:line_range_outside_file?, false)
|
|
|> assign(:suspicious_controls?, false)
|
|
|> assign(:error_kind, normalize_error(reason))
|
|
|> stream(:entries, [], reset: true)
|
|
|> stream(:lines, [], reset: true)
|
|
end
|
|
|
|
defp normalize_error(reason)
|
|
when reason in [
|
|
:invalid_commit_sha,
|
|
:invalid_path,
|
|
:empty_repository,
|
|
:identity_changed,
|
|
:commit_mismatch,
|
|
:not_found,
|
|
:tree_truncated,
|
|
:tree_too_large,
|
|
:blob_too_large,
|
|
:binary_blob,
|
|
:unsupported_entry,
|
|
:rate_limited,
|
|
:unavailable,
|
|
:invalid_response
|
|
],
|
|
do: reason
|
|
|
|
defp normalize_error(_reason), do: :unavailable
|
|
|
|
defp normalize_commit_sha(commit_sha) when is_binary(commit_sha) do
|
|
normalized = String.downcase(commit_sha)
|
|
|
|
if Regex.match?(@commit_sha_pattern, normalized) do
|
|
{:ok, normalized}
|
|
else
|
|
{:error, :invalid_commit_sha}
|
|
end
|
|
end
|
|
|
|
defp normalize_commit_sha(_commit_sha), do: {:error, :invalid_commit_sha}
|
|
|
|
defp normalize_route_path(nil), do: {:ok, ""}
|
|
|
|
defp normalize_route_path(path) when is_list(path) do
|
|
path
|
|
|> Enum.join("/")
|
|
|> RepositoryPath.normalize()
|
|
end
|
|
|
|
defp normalize_route_path(_path), do: {:error, :invalid_path}
|
|
|
|
defp parse_line_range(nil), do: {:ok, nil, false}
|
|
defp parse_line_range(""), do: {:ok, nil, false}
|
|
|
|
defp parse_line_range(value) when is_binary(value) do
|
|
case Regex.run(@line_range_pattern, value, capture: :all_but_first) do
|
|
[first, ""] -> validate_single_line(first)
|
|
[first] -> validate_single_line(first)
|
|
[first, last] -> validate_line_range(String.to_integer(first), String.to_integer(last))
|
|
nil -> {:ok, nil, true}
|
|
end
|
|
end
|
|
|
|
defp parse_line_range(_value), do: {:ok, nil, true}
|
|
|
|
defp validate_single_line(line) do
|
|
line = String.to_integer(line)
|
|
validate_line_range(line, line)
|
|
end
|
|
|
|
defp validate_line_range(first, last)
|
|
when last >= first and last <= @max_selected_line and
|
|
last - first <= @max_selected_span,
|
|
do: {:ok, {first, last}, false}
|
|
|
|
defp validate_line_range(_first, _last), do: {:ok, nil, true}
|
|
|
|
defp finding_line_range(%{line_start: line_start, line_end: line_end})
|
|
when is_integer(line_start) do
|
|
{line_start, line_end || line_start}
|
|
end
|
|
|
|
defp finding_line_range(_finding), do: nil
|
|
|
|
defp split_path(""), do: []
|
|
defp split_path(path), do: String.split(path, "/", trim: true)
|
|
|
|
defp file_lines("", _line_range), do: {:ok, []}
|
|
|
|
defp file_lines(content, line_range) do
|
|
lines = String.split(content, "\n", parts: @max_rendered_lines + 1, trim: false)
|
|
|
|
if length(lines) > @max_rendered_lines do
|
|
{:error, :too_many_lines}
|
|
else
|
|
{:ok,
|
|
lines
|
|
|> Enum.with_index(1)
|
|
|> Enum.map(fn {content, number} ->
|
|
%{number: number, content: content, highlighted?: line_selected?(number, line_range)}
|
|
end)}
|
|
end
|
|
end
|
|
|
|
defp line_selected?(_number, nil), do: false
|
|
defp line_selected?(number, {first, last}), do: number >= first and number <= last
|
|
|
|
defp line_range_outside_file?(nil, _line_count), do: false
|
|
|
|
defp line_range_outside_file?({first, last}, line_count),
|
|
do: first > line_count or last > line_count
|
|
|
|
defp sort_entries(entries) do
|
|
Enum.sort_by(entries, fn entry ->
|
|
{entry_type_order(entry.type), String.downcase(entry.name), entry.name}
|
|
end)
|
|
end
|
|
|
|
defp entry_with_finding_count(entry, finding_paths) do
|
|
count =
|
|
case entry.type do
|
|
:tree ->
|
|
prefix = entry.path <> "/"
|
|
Enum.count(finding_paths, &String.starts_with?(&1, prefix))
|
|
|
|
:blob ->
|
|
Enum.count(finding_paths, &(&1 == entry.path))
|
|
|
|
_unsupported ->
|
|
0
|
|
end
|
|
|
|
entry
|
|
|> Map.from_struct()
|
|
|> Map.put(:finding_count, count)
|
|
end
|
|
|
|
defp visible_file_finding_count(socket, commit_sha, path) do
|
|
socket
|
|
|> visible_finding_paths(commit_sha)
|
|
|> Enum.count(&(&1 == path))
|
|
end
|
|
|
|
defp visible_finding_paths(socket, commit_sha) do
|
|
socket.assigns.current_scope
|
|
|> Scans.list_scans(socket.assigns.repository)
|
|
|> Enum.filter(
|
|
&(&1.commit_sha == commit_sha and &1.details_visible and &1.review_status != "rejected")
|
|
)
|
|
|> Enum.flat_map(& &1.findings)
|
|
|> Enum.map(& &1.file_path)
|
|
end
|
|
|
|
defp refresh_finding_badges(%{assigns: %{browser_kind: :tree, tree: %Tree{} = tree}} = socket),
|
|
do: apply_browse_result(socket, {:ok, tree})
|
|
|
|
defp refresh_finding_badges(%{assigns: %{browser_kind: :file, file: %File{} = file}} = socket) do
|
|
assign(
|
|
socket,
|
|
:visible_finding_count,
|
|
visible_file_finding_count(socket, file.commit_sha, file.path)
|
|
)
|
|
end
|
|
|
|
defp refresh_finding_badges(socket), do: socket
|
|
|
|
defp entry_type_order(:tree), do: 0
|
|
defp entry_type_order(:blob), do: 1
|
|
defp entry_type_order(:symlink), do: 2
|
|
defp entry_type_order(:submodule), do: 3
|
|
|
|
defp entry_dom_id(entry) do
|
|
encoded_path = Base.url_encode64(entry.path, padding: false)
|
|
"code-entry-#{encoded_path}"
|
|
end
|
|
|
|
defp line_dom_id(line), do: "L#{line.number}"
|
|
|
|
defp repository_record_path(repository), do: RepositoryPaths.repository_path(repository)
|
|
|
|
defp repository_security_path(repository),
|
|
do: RepositoryPaths.repository_security_path(repository)
|
|
|
|
defp code_path(repository, commit_sha, path_segments),
|
|
do: RepositoryPaths.repository_code_path(repository, commit_sha, path_segments)
|
|
|
|
# Finding views stay on their report pin; free browse can switch branches.
|
|
defp maybe_load_branch_options(socket, :finding), do: socket
|
|
|
|
defp maybe_load_branch_options(socket, _action) do
|
|
repository = socket.assigns.repository
|
|
|
|
case RepositoryCode.list_branches(repository) do
|
|
{:ok, branches} ->
|
|
socket
|
|
|> assign(:branch_options, branches)
|
|
|> assign(
|
|
:selected_branch,
|
|
socket.assigns.selected_branch || repository.default_branch || List.first(branches)
|
|
)
|
|
|
|
{:error, _} ->
|
|
default = repository.default_branch
|
|
branches = if is_binary(default) and default != "", do: [default], else: []
|
|
|
|
socket
|
|
|> assign(:branch_options, branches)
|
|
|> assign(:selected_branch, default)
|
|
end
|
|
end
|
|
|
|
defp breadcrumb_items(path_segments) do
|
|
path_segments
|
|
|> Enum.with_index()
|
|
|> Enum.map(fn {name, index} ->
|
|
%{name: name, segments: Enum.take(path_segments, index + 1)}
|
|
end)
|
|
end
|
|
|
|
defp entry_path(repository, commit_sha, entry) do
|
|
code_path(repository, commit_sha, split_path(entry.path))
|
|
end
|
|
|
|
defp breadcrumb_path(repository, commit_sha, segments) do
|
|
code_path(repository, commit_sha, segments)
|
|
end
|
|
|
|
defp line_path(live_action, finding_ref, repository, commit_sha, path_segments, line_number) do
|
|
base =
|
|
if live_action == :finding do
|
|
~p"/findings/#{finding_ref}/code"
|
|
else
|
|
code_path(repository, commit_sha, path_segments)
|
|
end
|
|
|
|
if live_action == :finding do
|
|
base <> "#L#{line_number}"
|
|
else
|
|
base <> "?lines=#{line_number}#L#{line_number}"
|
|
end
|
|
end
|
|
|
|
defp format_bytes(nil), do: "-"
|
|
defp format_bytes(bytes) when bytes < 1_024, do: "#{bytes} B"
|
|
|
|
defp format_bytes(bytes) when bytes < 1_024 * 1_024,
|
|
do: "#{Float.round(bytes / 1_024, 1)} KB"
|
|
|
|
defp format_bytes(bytes), do: "#{Float.round(bytes / (1_024 * 1_024), 1)} MB"
|
|
|
|
defp short_sha(sha), do: String.slice(sha, 0, 7)
|
|
|
|
defp entry_icon(:tree), do: "hero-folder"
|
|
defp entry_icon(:blob), do: "hero-document-text"
|
|
defp entry_icon(:symlink), do: "hero-link"
|
|
defp entry_icon(:submodule), do: "hero-cube"
|
|
|
|
defp entry_label(:symlink), do: "symbolic link"
|
|
defp entry_label(:submodule), do: "submodule"
|
|
|
|
defp error_title(:invalid_commit_sha), do: "Invalid commit"
|
|
defp error_title(:invalid_path), do: "Invalid path"
|
|
defp error_title(:not_found), do: "Source not found"
|
|
defp error_title(:binary_blob), do: "Binary file"
|
|
defp error_title(:blob_too_large), do: "File too large"
|
|
defp error_title(:tree_too_large), do: "Directory too large"
|
|
defp error_title(:tree_truncated), do: "Incomplete directory"
|
|
defp error_title(:unsupported_entry), do: "Unsupported source entry"
|
|
defp error_title(:empty_repository), do: "Empty repository"
|
|
defp error_title(:rate_limited), do: "Code unavailable"
|
|
defp error_title(:identity_changed), do: "Repository identity changed"
|
|
defp error_title(:commit_mismatch), do: "Commit verification failed"
|
|
defp error_title(_error), do: "Code unavailable"
|
|
|
|
defp error_message(:invalid_commit_sha),
|
|
do: "Code views require a full 40-character commit SHA."
|
|
|
|
defp error_message(:invalid_path), do: "That repository path is not valid."
|
|
|
|
defp error_message(:not_found),
|
|
do: "No source exists at this path in the pinned commit."
|
|
|
|
defp error_message(:binary_blob),
|
|
do: "Tarakan does not render binary files as source code."
|
|
|
|
defp error_message(:blob_too_large),
|
|
do: "This file exceeds the safe source-preview limit."
|
|
|
|
defp error_message(reason) when reason in [:tree_too_large, :tree_truncated],
|
|
do: "This directory cannot be represented safely as a complete listing."
|
|
|
|
defp error_message(:unsupported_entry),
|
|
do: "This Git object type cannot be opened in the source browser."
|
|
|
|
defp error_message(:empty_repository),
|
|
do: "Nothing has been pushed to this repository yet. Push a branch and it will appear here."
|
|
|
|
defp error_message(:rate_limited),
|
|
do:
|
|
"Could not load this commit from git or GitHub yet. Wait a few seconds for the mirror to fill, then retry."
|
|
|
|
defp error_message(:unavailable),
|
|
do:
|
|
"Could not load this commit from git or GitHub yet. Wait a few seconds for the mirror to fill, then retry."
|
|
|
|
defp error_message(reason) when reason in [:identity_changed, :commit_mismatch],
|
|
do: "Tarakan could not verify this source against the registered public repository."
|
|
|
|
defp error_message(_error),
|
|
do:
|
|
"Could not load this commit from git or GitHub yet. Wait a few seconds for the mirror to fill, then retry."
|
|
|
|
defp suspicious_source_controls?(content) do
|
|
String.contains?(content, <<0>>) or
|
|
Enum.any?([0x202A, 0x202B, 0x202C, 0x202D, 0x202E, 0x2066, 0x2067, 0x2068, 0x2069], fn
|
|
codepoint -> String.contains?(content, <<codepoint::utf8>>)
|
|
end)
|
|
end
|
|
|
|
defp reauthorize_view(%{assigns: %{live_action: :finding}} = socket) do
|
|
repository = socket.assigns.repository
|
|
|
|
with %Repository{} = visible_repository <-
|
|
Repositories.get_visible_repository(
|
|
repository.host,
|
|
repository.owner,
|
|
repository.name,
|
|
socket.assigns.current_scope
|
|
),
|
|
{:ok, {scan, finding}} <-
|
|
Scans.get_finding(socket.assigns.current_scope, socket.assigns.finding_ref),
|
|
true <- scan.repository_id == visible_repository.id,
|
|
true <- scan.commit_sha == socket.assigns.source_commit_sha,
|
|
true <- finding.file_path == socket.assigns.source_path do
|
|
{:ok,
|
|
socket
|
|
|> assign(:repository, visible_repository)
|
|
|> assign(:finding, finding)}
|
|
else
|
|
_not_visible -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
defp reauthorize_view(socket) do
|
|
repository = socket.assigns.repository
|
|
|
|
case Repositories.get_visible_repository(
|
|
repository.host,
|
|
repository.owner,
|
|
repository.name,
|
|
socket.assigns.current_scope
|
|
) do
|
|
%Repository{} = visible_repository ->
|
|
{:ok, assign(socket, :repository, visible_repository)}
|
|
|
|
nil ->
|
|
{:error, :not_found}
|
|
end
|
|
end
|
|
|
|
defp evict_source(socket) do
|
|
repository = socket.assigns.repository
|
|
|
|
destination =
|
|
case Repositories.get_visible_repository(
|
|
repository.host,
|
|
repository.owner,
|
|
repository.name,
|
|
socket.assigns.current_scope
|
|
) do
|
|
%Repository{} = visible_repository -> repository_record_path(visible_repository)
|
|
nil -> ~p"/"
|
|
end
|
|
|
|
socket
|
|
|> clear_source()
|
|
|> push_navigate(to: destination)
|
|
end
|
|
|
|
defp clear_source(socket) do
|
|
socket
|
|
|> assign(:view_state, :loading)
|
|
|> assign(:browser_kind, nil)
|
|
|> assign(:commit_sha, nil)
|
|
|> assign(:path, "")
|
|
|> assign(:path_segments, [])
|
|
|> assign(:line_range, nil)
|
|
|> assign(:finding, nil)
|
|
|> assign(:tree, nil)
|
|
|> assign(:file, nil)
|
|
|> assign(:entry_count, 0)
|
|
|> assign(:visible_finding_count, 0)
|
|
|> assign(:line_count, 0)
|
|
|> assign(:line_range_outside_file?, false)
|
|
|> assign(:suspicious_controls?, false)
|
|
|> assign(:error_kind, nil)
|
|
|> stream(:entries, [], reset: true)
|
|
|> stream(:lines, [], reset: true)
|
|
end
|
|
end
|