defmodule GitGud.Packages.Formats.Listing do
@moduledoc """
A browsable directory index for the registries that are plain file
trees over HTTP.
apt and dnf both consume a mirror by fetching exact paths, and both
are conventionally *browsable* as well — `deb.debian.org/debian/` and
`download.fedoraproject.org/.../os/` are autoindexes you can click
down through. Ours only ever answered exact paths, so pointing a
browser at a repository got a 404 and there was no way to see whether
an upload landed without already knowing its name.
No client is on this path; it exists for the person checking.
The listing is derived from the paths the adapter says it serves,
never from the filesystem or a separate enumeration — so it cannot
advertise something that would 404, and it cannot leak a repository
the caller couldn't already read, because the repository list was
filtered before the adapter ever saw it.
"""
@typedoc """
One path the adapter answers to. `size` and `at` are optional, and
are `nil` for generated index documents: sizing those would mean
building all of them to draw a table.
"""
@type entry :: %{path: String.t(), size: non_neg_integer() | nil, at: DateTime.t() | nil}
@doc """
Render the directory at `ctx.path`, given every path the repository
serves.
Returns `{:error, :not_found}` for an empty listing below the root —
a path with no children is a path that doesn't exist, since the tree
has no empty nodes in it.
"""
@spec serve([entry()], String.t(), String.t(), map()) ::
{:ok, String.t(), iodata()} | {:error, :not_found}
def serve(paths, label, subtitle, %{path: prefix} = ctx) do
case children(paths, prefix) do
[] when prefix != [] -> {:error, :not_found}
entries -> {:ok, "text/html; charset=utf-8", page(label, subtitle, ctx, prefix, entries)}
end
end
# The immediate children of `prefix`: a path with exactly one segment
# left is a file, more than one means a directory we've now seen.
defp children(paths, prefix) do
depth = length(prefix)
paths
|> Enum.flat_map(fn entry ->
segments = String.split(entry.path, "/")
if List.starts_with?(segments, prefix) do
case Enum.drop(segments, depth) do
[name] -> [%{name: name, dir?: false, size: entry.size, at: entry.at}]
[name | _] -> [%{name: name, dir?: true, size: nil, at: nil}]
[] -> []
end
else
[]
end
end)
|> Enum.uniq_by(& &1.name)
|> Enum.sort_by(&{not &1.dir?, &1.name})
end
# A plain document rather than the app layout — this is served from
# the registry URL, which a browser may well reach with a Basic-auth
# prompt and no session at all — but it still wears the forge's
# colours.
#
# It pulls in `app.css` for the daisyUI theme definitions and repeats
# the root layout's theme bootstrap, so `data-theme` is set before
# first paint and the page matches whatever the viewer picked. Styling
# then goes through the theme's own custom properties rather than
# Tailwind utility classes: this module lives under `lib/git_gud`, and
# `app.css` only scans `lib/git_gud_web`, so utility classes written
# here would be purged out of the bundle and silently do nothing.
#
# The bootstrap is read-only — no picker on this page, so it never
# writes `phx:theme` — but it does listen for `storage`, so changing
# the theme in another tab recolours this one.
defp page(label, subtitle, ctx, prefix, entries) do
here = Enum.join([ctx.base_url | prefix], "/")
rows =
Enum.map(entries, fn entry ->
href = here <> "/" <> URI.encode(entry.name) <> if(entry.dir?, do: "/", else: "")
[
"<tr><td><a class=\"",
if(entry.dir?, do: "dir", else: "file"),
"\" href=\"",
esc(href),
"\">",
esc(entry.name),
if(entry.dir?, do: "/", else: ""),
"</a></td><td class=\"n\">",
format_size(entry.size),
"</td><td class=\"n\">",
format_date(entry.at),
"</td></tr>\n"
]
end)
parent =
if prefix == [] do
[]
else
up = Enum.join([ctx.base_url | Enum.drop(prefix, -1)], "/") <> "/"
["<tr><td><a class=\"dir\" href=\"", esc(up), "\">../</a></td><td></td><td></td></tr>\n"]
end
title = "/" <> Enum.join(prefix, "/")
[
"""
<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>#{esc(label)} #{esc(title)}</title>
<link rel="stylesheet" href="#{esc(stylesheet_path())}">
<script>
(() => {
const SYSTEM_DARK = "#{GitGud.Themes.system_dark()}";
const SYSTEM_LIGHT = "#{GitGud.Themes.system_light()}";
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const systemTheme = () => mq.matches ? SYSTEM_DARK : SYSTEM_LIGHT;
const apply = (theme) =>
document.documentElement.setAttribute("data-theme", theme || systemTheme());
apply(localStorage.getItem("phx:theme"));
mq.addEventListener("change", () => {
if (!localStorage.getItem("phx:theme")) apply(null);
});
window.addEventListener("storage", (e) => {
if (e.key === "phx:theme") apply(e.newValue);
});
})();
</script>
<style>
body {
background: var(--color-base-100);
color: var(--color-base-content);
font-family: ui-monospace, "B612 Mono", SFMono-Regular, Menlo, monospace;
font-size: 14px;
line-height: 1.6;
margin: 0 auto;
max-width: 60rem;
min-height: 100vh;
padding: 2rem 1rem;
}
h1 { font-size: 1rem; font-weight: 600; margin: 0 0 .25rem; }
h1 .path { opacity: .55; }
p.sub { margin: 0 0 1.5rem; opacity: .6; font-size: .8rem; }
table { border-collapse: collapse; width: 100%; }
td { padding: .25rem .75rem .25rem 0; border-bottom: 1px solid var(--color-base-300); }
td.n { text-align: right; white-space: nowrap; opacity: .6; font-size: .8rem; }
a { color: inherit; text-decoration: none; }
a:hover { text-decoration: underline; }
a.dir { color: var(--color-primary); }
</style></head><body>
<h1>#{esc(label)}<span class="path">#{esc(title)}</span></h1>
<p class="sub">#{esc(subtitle)}</p>
<table>
""",
parent,
rows,
"</table></body></html>\n"
]
end
# Digest-aware, so the stylesheet resolves in a release the same way
# the app layout's does.
defp stylesheet_path, do: GitGudWeb.Endpoint.static_path("/assets/css/app.css")
defp format_size(nil), do: ""
defp format_size(bytes) when bytes < 1024, do: "#{bytes} B"
defp format_size(bytes) when bytes < 1_048_576, do: "#{Float.round(bytes / 1024, 1)} KB"
defp format_size(bytes), do: "#{Float.round(bytes / 1_048_576, 1)} MB"
defp format_date(nil), do: ""
defp format_date(at), do: Calendar.strftime(at, "%Y-%m-%d %H:%M")
defp esc(value) do
value
|> to_string()
|> String.replace("&", "&")
|> String.replace("<", "<")
|> String.replace(">", ">")
|> String.replace("\"", """)
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
7.2 KiB · text
History
b868907